32 Commits
Author SHA1 Message Date
Rustam Khalimov e62dbfafba Стримить прогресс пакетной переклассификации
CardReclassifier отдаёт IProgress-отчёты, эндпоинт публикует SSE cards_reclassified {progress:true,...} каждые 5 карточек и финальное событие; фронт показывает done/total и перечитывает доску только по финалу. Закрывает BL-RECLASS-SSE.
2026-09-11 17:57:47 +03:00
Rustam Khalimov ea21dc54c6 Сверить счётчики api-map и пометить доки
Исправлен счётчик §3.5 (14→15 после добавления GET /cards/{id}/source), добавлено правило обновления; подтверждены пометки исторических доков. Закрывает TD-APIMAP-COUNT и TD-OLD-DOCS.
2026-09-11 17:53:14 +03:00
Rustam Khalimov 0f79bd4e70 Убрать мёртвые SSE-ветки и двойную перезагрузку доски
Удалены ветки boards_changed/pipeline_stats (core их не публикует); перезагрузка при переклассификации коалесцируется scheduleBoardReload. Закрывает TD-SSE-DEAD и TD-DBL-CLICK.
2026-09-11 17:52:05 +03:00
Rustam Khalimov e15be9de2e Добавить актора в access-лог HTTP
Строка access-лога включает actor (login пользователя/оператора) и tenant из HttpContext.Items. Закрывает BL-LOG-ACTOR.
2026-09-11 17:48:39 +03:00
Rustam Khalimov be457d7286 Добавить единый CI-прогон и workflow
scripts/ci.sh собирает все решения, гоняет тесты всех сервисов, сканирует уязвимые NuGet-зависимости и собирает фронт; build.sh/test.sh расширены на все решения. Плюс .github/workflows/ci.yml. Закрывает BL-CI.
2026-09-11 17:46:03 +03:00
Rustam Khalimov 1d7c9980ba Шардировать пакетную миграцию схем тенантов
Обход реестра идёт страницами (ITenantRepository.ListPageAsync, DefaultPageSize=200) с параллелизмом внутри страницы и изоляцией сбоев — масштаб на 1000+ схем без загрузки всего реестра. Закрывает BL-SCALE-1000.
2026-09-11 17:43:27 +03:00
Rustam Khalimov 38561528ab Ужесточить контейнеры сервисов
Прикладные образы работают non-root (deal, UID 10001, HOME=/tmp); в compose добавлены read_only root FS, tmpfs /tmp, no-new-privileges, cap_drop ALL и лимиты mem/cpus. Логи stateless-сервисов вынесены в /tmp/logs. Проверено compose config (dev/prod/observability).
2026-09-11 17:39:45 +03:00
Rustam Khalimov d2b716bbf4 Добавить метрику расхода ИИ-бюджета
Gauge deal.ai.budget.used.ratio{tenant} (доля израсходованного бюджета периода) собирается RuntimeDepthsCollector и публикуется DealMetricsCollector — основа для алертинга. Закрывает BL-ALERT-BUDGET.
2026-09-11 17:30:26 +03:00
Rustam Khalimov 000d433438 Актуализировать ТЗ под универсальный источник
Уточнены термины «источник», просмотр исходника (в т.ч. догрузка у сервиса-владельца), вкладка «Обработка» и добавлен пункт о сухом прогоне текста в настройках.
2026-09-11 17:30:26 +03:00
Rustam Khalimov db4455a554 Добавить remote-просмотр исходника карточки
RPC TelegramService.ReadSource + ISessionClient.GetMessageAsync, порт ITelegramGateway.ReadSourceAsync и провайдер TelegramSourceContentProvider в ядре; GET /api/cards/{id}/source догружает исходник у сервиса-владельца, в UI — кнопка «Обновить из источника». Медиа-посты пропускаются.
2026-09-11 17:22:06 +03:00
Rustam Khalimov c06ee1cf79 Добавить сухой прогон текста по конвейеру
POST /api/admin/check-message прогоняет текст через стоп-правила, глобальные исключения, ML и ИИ без создания карточки (PipelineWorkerService.DryRunAsync), отдаёт этапы, разбор и целевой контейнер. UI тестера в настройках переведён на новый контракт. Зафиксированы решения по вложениям (медиа-посты пропускаем).
2026-09-11 17:08:34 +03:00
Rustam Khalimov 2b25915790 Зафиксировать вопросы по вложениям источников
Отдельный документ по media→Storage и remote-просмотру исходника: что готово, что требует живого Telegram, предлагаемый план. Ссылки в backlog.
2026-09-11 16:33:26 +03:00
Rustam Khalimov 6d074834a7 Перевести входящий поток источников на generic-контракт
Добавлен sources.proto с PushSource; приём в ядре вынесен в SourceIngressGrpcService с SourceProtoMapper и ISourceIngestObserver, тенант определяется IngressTenantResolver. Из telegram.proto удалён PushMessage, telegram-сервис шлёт generic-записи, превью сохраняет TelegramSourceIngestObserver.
2026-09-11 16:32:49 +03:00
Rustam Khalimov 3327bf48b0 Добавить загрузку исходника карточки по провайдерам
Введены ISourceContentProvider и SourceContentResolver, эндпоинт GET /api/cards/{id}/source отдаёт содержимое источника (провайдер по kind либо сохранённое в карточке).
2026-09-11 15:01:22 +03:00
Rustam Khalimov ebc761d40e Перевести ядро на единый контракт источника
Карточка, очередь и отсев работают с SourceItem (SourceRef + SourceContent); Telegram-поля убраны из домена, персистентности, конвейера и wire, остались только в адаптере приёма. Tenant-миграции пересозданы с нуля. Фронт переведён на generic source/content с просмотрщиком вложений.
2026-09-11 14:58:54 +03:00
Rustam Khalimov 770dba7257 Добавить общий Storage-сервис данных
Новый сервис Deal.Storage (src/storage-service): gRPC Upload/
Download/Stat/Delete, определение типа контент-снифингом, MinIO-бэкенд,
токен-валидация через общий интерцептор. Контракт storage.proto в
Deal.Proto; sln сервиса; подключение в compose.dev и эндпоинт в core;
тесты (снифер + хост/токен).
2026-09-11 14:33:43 +03:00
Rustam Khalimov e1aebd1d78 Ввести единый контракт источника в домен Cards
Добавлены SourceItem/SourceRef/SourceContent/DataRef/ContactRef
(строго типизированный контракт без подтипов вложений; файлы —
ссылки на общий сервис данных). Удалены Telegram-ориентированные
source-интерфейсы из Cards; Card/ICard переведены на SourceRef.
2026-09-11 14:23:33 +03:00
Rustam Khalimov bf4c021ca9 Убрать доки, повторяющие имя/значение члена
summary вида «Ключ «x»» удалены; «Ключ «x»: пояснение» сжаты до
пояснения; summary, дословно равные имени/значению, удалены.
2026-09-11 13:51:31 +03:00
Rustam Khalimov 79c931d88e Почистить комментарии от ссылок на ТЗ и обрывков
Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/
дизайн-док/api-map/python/прототип) вместо построчного вырезания —
без обрывков фраз; снят боилерплейт <param>/<returns>; то же в
.proto.
2026-09-11 13:49:26 +03:00
Rustam Khalimov b053d58335 Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
2026-09-11 13:39:39 +03:00
Rustam Khalimov 5f5538d33b Разложить grpc-hosting и UrlSafeToken по назначению
grpc-hosting -> Interceptors/Models/Options/Services; UrlSafeToken
-> SharedKernel/Utilities. Маркеры проектов оставлены в корнях.
namespace/using/FQN мигрированы.
2026-09-11 13:33:19 +03:00
Rustam Khalimov 1e118e9bba Зафиксировать структуру проектов в код-стайле
Правило: namespace = путь папки; состав папок по назначению и
группировка тестовых проектов по областям (Support для общих
хелперов).
2026-09-11 13:31:06 +03:00
Rustam Khalimov e8b9fab860 Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using,
оставшиеся после миграции namespace (676 файлов).
2026-09-11 13:30:57 +03:00
Rustam Khalimov 5bfa92a4ac Сгруппировать тестовые проекты по областям
Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
2026-09-11 13:28:26 +03:00
Rustam Khalimov e3a2692507 Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
2026-09-11 13:25:18 +03:00
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00
Rustam Khalimov cd0b3b606b Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
2026-09-11 13:18:14 +03:00
Rustam Khalimov 31c434ed93 Разбить Deal.Modules.Cards по назначению
Application разделён на Abstractions (25 интерфейсов), Models (13
доменных типов) и Dtos (CardMoveResultDto); namespace приведён к путям,
using потребителей мигрированы.
2026-09-11 13:16:57 +03:00
Rustam Khalimov 8a7d7f2a7a Добавить дизайн структуры проектов 2026-09-11 13:15:12 +03:00
Rustam Khalimov 413eaac48c Вынести условия-предикаты в extension-методы
HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser;
15 приватных предикатов заменены extension-методами с удалением
дублирующих приватных методов: IsCommunicationFailure,
IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue,
IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget,
HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint,
IsTypeLabel.
2026-09-11 13:08:47 +03:00
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00
1186 changed files with 132310 additions and 122763 deletions
+27 -27
View File
@@ -1,27 +1,27 @@
# зависимости и сборки # зависимости и сборки
**/node_modules **/node_modules
**/dist **/dist
**/.cache **/.cache
# данные и секреты # данные и секреты
data data
backend/data backend/data
.env .env
*.log *.log
# mTLS-сертификаты (deploy/certs): генерируются scripts/mtls-certs.sh на хосте, в build-контекст и образ не попадают # mTLS-сертификаты (deploy/certs): генерируются scripts/mtls-certs.sh на хосте, в build-контекст и образ не попадают
deploy/certs deploy/certs
# git/мусор # git/мусор
.git .git
.gitignore .gitignore
QUESTIONS.md QUESTIONS.md
QUESTIONS2.md QUESTIONS2.md
ТЗ.md ТЗ.md
__pycache__ __pycache__
**/__pycache__ **/__pycache__
*.pyc *.pyc
# .NET build outputs (context сборки — корень репозитория: compose.dev.yml build.context: ..) # .NET build outputs (context сборки — корень репозитория: compose.dev.yml build.context: ..)
**/bin **/bin
**/obj **/obj
+73 -75
View File
@@ -1,75 +1,73 @@
root = true root = true
[*] [*]
charset = utf-8 charset = utf-8
# LF: пишут инструменты проекта (Python/Node), требуется shell-скриптам на Linux CI end_of_line = crlf
end_of_line = lf insert_final_newline = true
insert_final_newline = true indent_style = space
indent_style = space indent_size = 4
indent_size = 4 trim_trailing_whitespace = true
trim_trailing_whitespace = true
[*.{cs,vb}]
[*.{cs,vb}] indent_size = 4
indent_size = 4
# Стиль фигурных скобок — Allman (на отдельной строке)
# Стиль фигурных скобок — Allman (на отдельной строке) csharp_new_line_before_open_brace = all
csharp_new_line_before_open_brace = all csharp_new_line_before_else = true
csharp_new_line_before_else = true csharp_new_line_before_catch = true
csharp_new_line_before_catch = true csharp_new_line_before_finally = true
csharp_new_line_before_finally = true
# using — в начале файла
# using — в начале файла dotnet_sort_system_directives_first = true
dotnet_sort_system_directives_first = true
# Модификаторы доступа — всегда явные
# Модификаторы доступа — всегда явные dotnet_style_require_accessibility_modifiers = always:error
dotnet_style_require_accessibility_modifiers = always:error
# Квалификация this. — запрещена (поля, свойства, методы, события)
# Квалификация this. — запрещена (поля, свойства, методы, события) dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_field = false:warning dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_property = false:warning dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_event = false:warning
dotnet_style_qualification_for_event = false:warning
# Члены
# Члены csharp_style_var_for_built_in_types = false:silent
# var — запрещён для встроенных типов (ломает сборку), для очевидных/прочих — silent (§4 код-стайла) csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_for_built_in_types = false:warning csharp_style_var_elsewhere = false:silent
csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent [*.cs]
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
[*.cs] dotnet_diagnostic.IDE0290.severity = none
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
dotnet_diagnostic.IDE0290.severity = none # --- Правила именования ---
# Приватные const-поля: PascalCase
# --- Правила именования --- # Приватные static readonly-поля (константоподобные): PascalCase
# Приватные const-поля: PascalCase # Остальные приватные поля: обязательный префикс `_` + camelCase
# Приватные static readonly-поля (константоподобные): PascalCase dotnet_naming_rule.private_const_fields_pascal.severity = warning
# Остальные приватные поля: обязательный префикс `_` + camelCase dotnet_naming_rule.private_const_fields_pascal.symbols = private_const_fields
dotnet_naming_rule.private_const_fields_pascal.severity = warning dotnet_naming_rule.private_const_fields_pascal.style = pascal_case_style
dotnet_naming_rule.private_const_fields_pascal.symbols = private_const_fields
dotnet_naming_rule.private_const_fields_pascal.style = pascal_case_style dotnet_naming_symbols.private_const_fields.applicable_kinds = field
dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_const_fields.applicable_kinds = field dotnet_naming_symbols.private_const_fields.required_modifiers = const
dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_const_fields.required_modifiers = const dotnet_naming_rule.private_static_readonly_fields_pascal.severity = warning
dotnet_naming_rule.private_static_readonly_fields_pascal.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_pascal.severity = warning dotnet_naming_rule.private_static_readonly_fields_pascal.style = pascal_case_style
dotnet_naming_rule.private_static_readonly_fields_pascal.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_pascal.style = pascal_case_style dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly dotnet_naming_style.pascal_case_style.capitalization = pascal_case
dotnet_naming_style.pascal_case_style.capitalization = pascal_case dotnet_naming_rule.private_fields_underscore_camel.severity = warning
dotnet_naming_rule.private_fields_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_underscore_camel.severity = warning dotnet_naming_rule.private_fields_underscore_camel.style = underscore_camel_style
dotnet_naming_rule.private_fields_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_underscore_camel.style = underscore_camel_style dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_style.underscore_camel_style.required_prefix = _
dotnet_naming_style.underscore_camel_style.capitalization = camel_case
dotnet_naming_style.underscore_camel_style.required_prefix = _
dotnet_naming_style.underscore_camel_style.capitalization = camel_case dotnet_diagnostic.IDE1006.severity = warning
dotnet_diagnostic.IDE1006.severity = warning
-20
View File
@@ -1,20 +0,0 @@
# Нормализация концов строк: в репозитории и рабочей копии — 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
-79
View File
@@ -1,79 +0,0 @@
name: ci
# Прогоняем CI в двух случаях:
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
# Обычные коммиты в ветки без открытого МР CI не запускают.
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch:
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
# Завершённые (успех/фейл) прогоны не трогаются.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: Сборка 5 решений
run: |
dotnet build src/core/Deal.sln --nologo -v q
dotnet build src/telegram-service/Deal.Telegram.sln --nologo -v q
dotnet build src/ai-service/Deal.Ai.sln --nologo -v q
dotnet build src/ml-service/Deal.Ml.sln --nologo -v q
dotnet build src/storage-service/Deal.Storage.sln --nologo -v q
- name: Тесты
run: |
dotnet test src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --nologo
dotnet test src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj --nologo --no-build
dotnet test src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj --nologo --no-build
dotnet test src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj --nologo --no-build
dotnet test src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj --nologo --no-build
- name: Скан уязвимых NuGet-зависимостей
run: |
fail=0
for sln in src/core/Deal.sln src/telegram-service/Deal.Telegram.sln src/ai-service/Deal.Ai.sln src/ml-service/Deal.Ml.sln src/storage-service/Deal.Storage.sln; do
echo "-- $sln"
OUTPUT=$(dotnet list "$sln" package --vulnerable --include-transitive 2>&1 || true)
if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then
printf '%s\n' "$OUTPUT"
echo "ОШИБКА: уязвимые зависимости в $sln"
fail=1
fi
done
exit $fail
- name: Фронтенд (сборка + линтер i18n)
run: |
cd src/frontend
npm ci
npm run build
npm run lint:i18n
+28
View File
@@ -0,0 +1,28 @@
name: ci
on:
push:
pull_request:
workflow_dispatch:
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: CI (сборка, тесты, скан уязвимостей, фронт)
run: sh scripts/ci.sh
+32 -47
View File
@@ -1,47 +1,32 @@
# === .NET === # === .NET ===
**/bin/ **/bin/
**/obj/ **/obj/
*.user *.user
*.suo *.suo
.vs/ .vs/
*.nupkg *.nupkg
# === Node / фронтенд === # === Node / фронтенд ===
**/node_modules/ **/node_modules/
**/dist/ **/dist/
*.log *.log
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*
yarn-error.log* yarn-error.log*
# === IDE / редакторы === # === IDE / редакторы ===
.idea/ .idea/
.vscode/ .vscode/
# === Секреты и шаблоны окружения === # === Секреты и шаблоны окружения ===
# реальные .env не коммитим, но примеры (*.example) — храним # реальные .env не коммитим, но примеры (*.example) — храним
.env .env
.env.* .env.*
!*.example !*.example
# === Локальные сертификаты и ключи (генерируются скриптами) === # === Локальные сертификаты и ключи (генерируются скриптами) ===
deploy/certs/ deploy/certs/
# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) === # === Рантайм-данные (БД, объектное хранилище, ключи шифрования) ===
archive/leadradar-legacy/data/ archive/leadradar-legacy/data/
src/core/Deal.Api/data/ src/core/Deal.Api/data/
# Python
__pycache__/
*.pyc
# Gitea runner: секреты и локальное состояние
!deploy/gitea-runner/.env.example
deploy/gitea-runner/.env
deploy/gitea-runner/data/
# Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально
scripts/
# Локальный клон вики
.wiki-clone/
@@ -1,61 +1,61 @@
# Discovery — финальная волна правок: отчёт # Discovery — финальная волна правок: отчёт
**Статус: ГОТОВО** — все findings закрыты, проверки (компиляция, сборки, смоук) зелёные. **Статус: ГОТОВО** — все findings закрыты, проверки (компиляция, сборки, смоук) зелёные.
## Что исправлено ## Что исправлено
| Finding | Файлы | Суть | | Finding | Файлы | Суть |
| --- | --- | --- | | --- | --- | --- |
| I1 + M1 + m2 (авто-join) | `db.py`, `discovery.py`, `discovery_worker.py` | Колонка `disc_candidates.join_failures``_SCHEMA` + `_MIGRATIONS`), `joinFailures` во view кандидата. `_join_step`: после `wait_join_delay()` кандидат перечитывается (SELECT по dialog_id) и join выполняется только если запись есть, `status='review'`, задача `running`+`autoJoin`, `_we_are_in()` False — иначе выход без join. Ошибка join (не flood): `join_failures += 1`; на 3-й неудаче `delete_candidate` + лог skip «не удалось вступить (3 попытки): {err}». FloodWaitError — как было (note_flood + лог flood, кандидат остаётся review). После успеха: `mark_joined``add_dialog_monitored``await tg.backfill_dialog(dialog_id)``remove_blacklist`. | | I1 + M1 + m2 (авто-join) | `db.py`, `discovery.py`, `discovery_worker.py` | Колонка `disc_candidates.join_failures``_SCHEMA` + `_MIGRATIONS`), `joinFailures` во view кандидата. `_join_step`: после `wait_join_delay()` кандидат перечитывается (SELECT по dialog_id) и join выполняется только если запись есть, `status='review'`, задача `running`+`autoJoin`, `_we_are_in()` False — иначе выход без join. Ошибка join (не flood): `join_failures += 1`; на 3-й неудаче `delete_candidate` + лог skip «не удалось вступить (3 попытки): {err}». FloodWaitError — как было (note_flood + лог flood, кандидат остаётся review). После успеха: `mark_joined``add_dialog_monitored``await tg.backfill_dialog(dialog_id)``remove_blacklist`. |
| I2 (flood/ошибки поиска) | `discovery_worker.py` | `tg.discovery_search` обёрнут в try/except: FloodWaitError → `note_flood()` + лог flood + return; прочие Exception → лог error «поиск «{keyword}»: {err}» + return; `advance_search` только после успешного поиска. | | I2 (flood/ошибки поиска) | `discovery_worker.py` | `tg.discovery_search` обёрнут в try/except: FloodWaitError → `note_flood()` + лог flood + return; прочие Exception → лог error «поиск «{keyword}»: {err}» + return; `advance_search` только после успешного поиска. |
| I3 (backfill после join) | `discovery_worker.py`, `discovery_routes.py` | В обоих местах join: `await tg.backfill_dialog(dialog_id)` после `add_dialog_monitored` (best-effort: исключение не роняет шаг/API — `log.warning`). | | I3 (backfill после join) | `discovery_worker.py`, `discovery_routes.py` | В обоих местах join: `await tg.backfill_dialog(dialog_id)` после `add_dialog_monitored` (best-effort: исключение не роняет шаг/API — `log.warning`). |
| I4 (отсев «чатов») | `discovery_worker.py` | В `_search_step` результаты с `kind='чат'` (люди/личные чаты/боты) пропускаются: `continue` + лог skip «{name}: личный чат/бот»; каналы/группы/форумы — как раньше. | | I4 (отсев «чатов») | `discovery_worker.py` | В `_search_step` результаты с `kind='чат'` (люди/личные чаты/боты) пропускаются: `continue` + лог skip «{name}: личный чат/бот»; каналы/группы/форумы — как раньше. |
| M4/m1 (идемпотентность reject + ЧС) | `discovery.py`, `discovery_worker.py`, `discovery_routes.py` | `mark_rejected`: ранний return при `status='rejected'` (счётчик/лог/чёрный список не трогаются). После успешного join (worker и routes) — `discovery.remove_blacklist(dialog_id)`. | | M4/m1 (идемпотентность reject + ЧС) | `discovery.py`, `discovery_worker.py`, `discovery_routes.py` | `mark_rejected`: ранний return при `status='rejected'` (счётчик/лог/чёрный список не трогаются). После успешного join (worker и routes) — `discovery.remove_blacklist(dialog_id)`. |
| M3 (фронт-чистота) | `store.js`, `DiscoveryView.vue` | Удалён мёртвый `state.discCandidateStatus` (проверено: читается только в docs; вью использует локальный `activeTab`) вместе с записями в `loadDiscCandidates`/`resetLocal`. `resetLocal` сбрасывает `discCounts``discQuota`). Квоты переехали в store: `state.discQuota {limit,delayMin,delayMax,paused}` + `loadDiscQuota()` (GET /api/settings), `saveDiscQuota(patch)` (PATCH), `toggleDiscPaused()`; прямой `import { api, ApiError }` из вью убран. | | M3 (фронт-чистота) | `store.js`, `DiscoveryView.vue` | Удалён мёртвый `state.discCandidateStatus` (проверено: читается только в docs; вью использует локальный `activeTab`) вместе с записями в `loadDiscCandidates`/`resetLocal`. `resetLocal` сбрасывает `discCounts``discQuota`). Квоты переехали в store: `state.discQuota {limit,delayMin,delayMax,paused}` + `loadDiscQuota()` (GET /api/settings), `saveDiscQuota(patch)` (PATCH), `toggleDiscPaused()`; прямой `import { api, ApiError }` из вью убран. |
| M5 (инвариант пауз) | `settings_routes.py` | `patch_settings`: если пришли оба `discJoinDelayMin`/`discJoinDelayMax` — после клампов (5..600), при min>max значения меняются местами (как делает фронт). | | M5 (инвариант пауз) | `settings_routes.py` | `patch_settings`: если пришли оба `discJoinDelayMin`/`discJoinDelayMax` — после клампов (5..600), при min>max значения меняются местами (как делает фронт). |
| m6 (потеря имени) | `discovery_worker.py` | `_eval_step`: name/username обновляются только при успешном резолве `discovery_info` (`resolved`); fallback (`name=dialog_id`) больше не затирает имя кандидата. | | m6 (потеря имени) | `discovery_worker.py` | `_eval_step`: name/username обновляются только при успешном резолве `discovery_info` (`resolved`); fallback (`name=dialog_id`) больше не затирает имя кандидата. |
| Синхронизация дизайн-дока | `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` | §9: у кандидата `lang_ru` (не lang_detected), добавлен `join_failures`; topics = {topicId,title,fitCount,total,fitRatio,passed}; нет транзитного статуса `evaluated` (счётчик на задаче). | | Синхронизация дизайн-дока | `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` | §9: у кандидата `lang_ru` (не lang_detected), добавлен `join_failures`; topics = {topicId,title,fitCount,total,fitRatio,passed}; нет транзитного статуса `evaluated` (счётчик на задаче). |
## Проверки (команды и вывод) ## Проверки (команды и вывод)
1. Компиляция бэкенда: 1. Компиляция бэкенда:
``` ```
$ cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py \ $ cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py \
backend/app/services/discovery.py backend/app/routers/discovery_routes.py \ backend/app/services/discovery.py backend/app/routers/discovery_routes.py \
backend/app/db.py backend/app/routers/settings_routes.py backend/app/db.py backend/app/routers/settings_routes.py
PY_COMPILE_OK PY_COMPILE_OK
``` ```
2. Сборка образа (обязательно после правок бэкенда/db.py): 2. Сборка образа (обязательно после правок бэкенда/db.py):
``` ```
$ docker compose build app $ docker compose build app
[+] build 1/1 [+] build 1/1
✔ Image telbase-app Built 6.4s ✔ Image telbase-app Built 6.4s
``` ```
(внутри образа повторно собран и фронтенд: `vite build` → 42 modules transformed, ok) (внутри образа повторно собран и фронтенд: `vite build` → 42 modules transformed, ok)
3. Сборка фронтенда: 3. Сборка фронтенда:
``` ```
$ cd /c/telbase/frontend && npm run build $ cd /c/telbase/frontend && npm run build
vite v6.4.3 building for production... vite v6.4.3 building for production...
✓ 42 modules transformed. ✓ 42 modules transformed.
✓ built in 1.40s ✓ built in 1.40s
``` ```
4. Смоук на временной БД в контейнере (все Telegram-вызовы замоканы): 4. Смоук на временной БД в контейнере (все Telegram-вызовы замоканы):
``` ```
$ MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \ $ MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \
-e LEADRADAR_DATA=/tmp/lr_fix --entrypoint python app /data/lr_fix_smoke.py -e LEADRADAR_DATA=/tmp/lr_fix --entrypoint python app /data/lr_fix_smoke.py
A_REJECT_IDEMPOTENT_OK # mark_rejected повторно: rejected=1, 1 reject-лог, 1 запись ЧС A_REJECT_IDEMPOTENT_OK # mark_rejected повторно: rejected=1, 1 reject-лог, 1 запись ЧС
B_JOIN_FAILURES_OK # join_failures 1→2, на 3-й неудаче кандидат удалён + skip «3 попытки» B_JOIN_FAILURES_OK # join_failures 1→2, на 3-й неудаче кандидат удалён + skip «3 попытки»
B_JOIN_RECHECK_OK # задача на паузе во время delay → join не вызывается (action none) B_JOIN_RECHECK_OK # задача на паузе во время delay → join не вызывается (action none)
C_SEARCH_ERROR_TICK_OK # ошибка поиска: tick жив (action error), searchIdx не двигается C_SEARCH_ERROR_TICK_OK # ошибка поиска: tick жив (action error), searchIdx не двигается
D_AUTOJOIN_OK # успешный авто-join: joined/auto_joined, dialogs(monitor), backfill вызван D_AUTOJOIN_OK # успешный авто-join: joined/auto_joined, dialogs(monitor), backfill вызван
LR_FIX_SMOKE_OK LR_FIX_SMOKE_OK
``` ```
Временный скрипт `data/lr_fix_smoke.py` удалён после прогона; `progress.md` не трогался. Временный скрипт `data/lr_fix_smoke.py` удалён после прогона; `progress.md` не трогался.
## Concerns ## Concerns
1. Ветка flood-ошибки поиска и реальные сетевые ошибки join офлайн не воспроизводятся (нужен живой аккаунт); покрыты код-ревью и логикой (в `tg.discovery_join` flood фиксируется и пробрасывается, worker дублирует `note_flood` идемпотентно). 1. Ветка flood-ошибки поиска и реальные сетевые ошибки join офлайн не воспроизводятся (нужен живой аккаунт); покрыты код-ревью и логикой (в `tg.discovery_join` flood фиксируется и пробрасывается, worker дублирует `note_flood` идемпотентно).
2. При отсеве «личный чат/бот» пишется skip-лог на каждого человека в выдаче поиска — история задачи может стать многословной на «широких» ключах (по ТЗ-рекомендации инструкции; при желании можно убрать лог, оставив continue). 2. При отсеве «личный чат/бот» пишется skip-лог на каждого человека в выдаче поиска — история задачи может стать многословной на «широких» ключах (по ТЗ-рекомендации инструкции; при желании можно убрать лог, оставив continue).
3. `_join_step` при нарушении условий re-check после паузы выходит молча (`{"action":"none"}`) без лога — намеренно (ситуация штатная: задача поставлена на паузу/кандидат отклонён человеком). Ошибки же join всегда логируются. 3. `_join_step` при нарушении условий re-check после паузы выходит молча (`{"action":"none"}`) без лога — намеренно (ситуация штатная: задача поставлена на паузу/кандидат отклонён человеком). Ошибки же join всегда логируются.
4. В `db.py` и `settings_routes.py` остались предсуществующие замечания линтера, не связанные с этой волной: db.py — сортировка импортов (L7), try/except-pass и «голый» Exception в нетронутых местах Store (миграции/close/all_settings); settings_routes.py — неиспользуемые импорты `json`/`HTTPException`/`tg` и неиспользуемая `provider_id` в `ai_check`. Новые правки этих замечаний не добавляют (проверено: файлы воркера/сервиса/роута discovery чисты). 4. В `db.py` и `settings_routes.py` остались предсуществующие замечания линтера, не связанные с этой волной: db.py — сортировка импортов (L7), try/except-pass и «голый» Exception в нетронутых местах Store (миграции/close/all_settings); settings_routes.py — неиспользуемые импорты `json`/`HTTPException`/`tg` и неиспользуемая `provider_id` в `ai_check`. Новые правки этих замечаний не добавляют (проверено: файлы воркера/сервиса/роута discovery чисты).
5. `join_failures` не сбрасывается при переводе кандидата обратно в review после удачной паузы — не требуется: при повторном добавлении (после rejected) запись создаётся заново с `DEFAULT 0`, а успешный join переводит кандидата в `joined`. 5. `join_failures` не сбрасывается при переводе кандидата обратно в review после удачной паузы — не требуется: при повторном добавлении (после rejected) запись создаётся заново с `DEFAULT 0`, а успешный join переводит кандидата в `joined`.
@@ -1,41 +1,41 @@
# Discovery — scoped-ревью фикс-волны: вердикт и доработки # Discovery — scoped-ревью фикс-волны: вердикт и доработки
**Вердикт ревьюера: Ready — With fixes** (все 8 заявленных фиксов I1–I4/M1–M5/m1–m6 подтверждены код-ревью; 2 Important + 4 Minor). **Вердикт ревьюера: Ready — With fixes** (все 8 заявленных фиксов I1–I4/M1–M5/m1–m6 подтверждены код-ревью; 2 Important + 4 Minor).
Смоук-прогон новых веток после правок — зелёный (A–E), образ пересобран, контейнер поднят, API 200. Смоук-прогон новых веток после правок — зелёный (A–E), образ пересобран, контейнер поднят, API 200.
## Findings ревьюера и что сделано ## Findings ревьюера и что сделано
### Important ### Important
1. **Search-flood retry storm** (`discovery_worker.py`) — при `FloodWaitError` ключ не двигался, а тик каждые 5 с снова дёргал `discovery_search` весь день (флуд > 60 с Telethon не гасит сам), спамя лог flood и блокируя eval/join всех задач. 1. **Search-flood retry storm** (`discovery_worker.py`) — при `FloodWaitError` ключ не двигался, а тик каждые 5 с снова дёргал `discovery_search` весь день (флуд > 60 с Telethon не гасит сам), спамя лог flood и блокируя eval/join всех задач.
**Исправлено**: `tick()` теперь при `ban_guard.flood_today()` возвращает none (полный стоп discovery-сетевых действий до конца суток — согласуется с «стоп до конца суток» из note_flood); generic-ошибка ключа — после 3 попыток подряд ключ пропускается (`advance_search` + лог «ключ пропущен»), счётчик ошибок сбрасывается при успешном поиске. **Исправлено**: `tick()` теперь при `ban_guard.flood_today()` возвращает none (полный стоп discovery-сетевых действий до конца суток — согласуется с «стоп до конца суток» из note_flood); generic-ошибка ключа — после 3 попыток подряд ключ пропускается (`advance_search` + лог «ключ пропущен»), счётчик ошибок сбрасывается при успешном поиске.
2. **Re-check после wait_join_delay без BanGuard** (`_join_step`) — за паузу 50–70 с пользователь мог нажать стоп-кран / случиться флуд, а pending-join всё равно выполнялся. 2. **Re-check после wait_join_delay без BanGuard** (`_join_step`) — за паузу 50–70 с пользователь мог нажать стоп-кран / случиться флуд, а pending-join всё равно выполнялся.
**Исправлено**: в условие после паузы добавлено `not ban_guard.can_auto_join()` (покрывает стоп-кран, flood дня и суточный лимит). **Исправлено**: в условие после паузы добавлено `not ban_guard.can_auto_join()` (покрывает стоп-кран, flood дня и суточный лимит).
### Minor ### Minor
3. **Single-key PATCH ломает инвариант min ≤ max пауз**`PATCH {discJoinDelayMax: 5}` при сохранённом min=600 давал инверсию → `random.uniform` падал на каждом join-тике. 3. **Single-key PATCH ломает инвариант min ≤ max пауз**`PATCH {discJoinDelayMax: 5}` при сохранённом min=600 давал инверсию → `random.uniform` падал на каждом join-тике.
**Исправлено** в двух местах: `settings_routes.patch_settings` клампит одиночный конец интервала относительно сохранённого другого; `ban_guard.wait_join_delay` защитно меняет концы местами (и выходит без паузы, если обе настройки 0). **Исправлено** в двух местах: `settings_routes.patch_settings` клампит одиночный конец интервала относительно сохранённого другого; `ban_guard.wait_join_delay` защитно меняет концы местами (и выходит без паузы, если обе настройки 0).
4. **UPDATE join_failures / delete на 3-й неудаче без ре-валидации status='review'** — узкая гонка: человека отклонил кандидата между re-read и падением join. 4. **UPDATE join_failures / delete на 3-й неудаче без ре-валидации status='review'** — узкая гонка: человека отклонил кандидата между re-read и падением join.
**Исправлено**: перед инкрементом счётчика статус перечитывается (`row_now`); UPDATE идёт с `AND status='review'`; при выходе из review воркер выходит молча, не трогая запись. **Исправлено**: перед инкрементом счётчика статус перечитывается (`row_now`); UPDATE идёт с `AND status='review'`; при выходе из review воркер выходит молча, не трогая запись.
5. **Ручной join в API ждал inline-backfill ~1530 с** (`backfill_dialog` спит 1.5–3 с/сообщение) — кнопка «Вступить» висела, прокси с коротким таймаутом показал бы ложную ошибку. 5. **Ручной join в API ждал inline-backfill ~1530 с** (`backfill_dialog` спит 1.5–3 с/сообщение) — кнопка «Вступить» висела, прокси с коротким таймаутом показал бы ложную ошибку.
**Исправлено**: `POST /candidates/{id}/join` запускает backfill в фоне через `_spawn(_backfill_quiet(...))` (паттерн set_monitor_all), ответ API быстрый, ошибки backfill не роняют запрос. **Исправлено**: `POST /candidates/{id}/join` запускает backfill в фоне через `_spawn(_backfill_quiet(...))` (паттерн set_monitor_all), ответ API быстрый, ошибки backfill не роняют запрос.
6. **Skip-лог на каждого человека в поиске** — известный minor из отчёта фиксера (concern #2). Оставлен как есть: по одному логу на источник информативно для истории задачи; при желании можно агрегировать («пропущено личных чатов: N») — не критично. 6. **Skip-лог на каждого человека в поиске** — известный minor из отчёта фиксера (concern #2). Оставлен как есть: по одному логу на источник информативно для истории задачи; при желании можно агрегировать («пропущено личных чатов: N») — не критично.
## Smoke новых веток (временный скрипт в data/, удалён после прогона) ## Smoke новых веток (временный скрипт в data/, удалён после прогона)
``` ```
A_FLOOD_GATE_OK # flood_today -> tick none, search не вызван A_FLOOD_GATE_OK # flood_today -> tick none, search не вызван
B_PAUSE_DURING_DELAY_OK # стоп-кран во сне -> join не выполнен, кандидат review B_PAUSE_DURING_DELAY_OK # стоп-кран во сне -> join не выполнен, кандидат review
C_JOIN_FAILURES_REVIEW_GONE_OK # кандидат rejected во время падения join: счётчик 0, запись жива C_JOIN_FAILURES_REVIEW_GONE_OK # кандидат rejected во время падения join: счётчик 0, запись жива
D_INVERTED_DELAYS_OK # min>max: wait_join_delay не падает (swap) D_INVERTED_DELAYS_OK # min>max: wait_join_delay не падает (swap)
E_SEARCH_KEY_SKIP_OK # 3 ошибки ключа -> ключ пропущен, searchDone, лог «ключ пропущен» E_SEARCH_KEY_SKIP_OK # 3 ошибки ключа -> ключ пропущен, searchDone, лог «ключ пропущен»
LR_REVIEW_FIX_SMOKE_OK LR_REVIEW_FIX_SMOKE_OK
``` ```
## Проверки ## Проверки
1. `python -m py_compile` изменённых файлов (discovery_worker.py, ban_guard.py, discovery_routes.py, settings_routes.py) — OK. 1. `python -m py_compile` изменённых файлов (discovery_worker.py, ban_guard.py, discovery_routes.py, settings_routes.py) — OK.
2. `docker compose build app` — Built (внутри образа повторный `vite build` — ok). 2. `docker compose build app` — Built (внутри образа повторный `vite build` — ok).
3. `docker compose up -d app` — контейнер Recreated/Started. 3. `docker compose up -d app` — контейнер Recreated/Started.
4. `GET /api/health` — 200 `{"ok":true,...}`; login admin — 200; `/api/settings` отдаёт disc-ключи и `discPaused: false`; `/api/discovery/tasks` и `/blacklist` — 200 `{"items":[]}`. 4. `GET /api/health` — 200 `{"ok":true,...}`; login admin — 200; `/api/settings` отдаёт disc-ключи и `discPaused: false`; `/api/discovery/tasks` и `/blacklist` — 200 `{"items":[]}`.
## Осталось ## Осталось
- Живой E2E с Telegram-аккаунтом (шаги в task-10-report.md §«Осталось для ручной проверки») — вместе с пользователем: поиск → кандидаты → вступить/отклонить → авто-вступление с паузами и расходом лимита. Реальные flood/форумы офлайн не воспроизводятся. - Живой E2E с Telegram-аккаунтом (шаги в task-10-report.md §«Осталось для ручной проверки») — вместе с пользователем: поиск → кандидаты → вступить/отклонить → авто-вступление с паузами и расходом лимита. Реальные flood/форумы офлайн не воспроизводятся.
+28 -28
View File
@@ -1,28 +1,28 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-04-channel-discovery.md # SDD ledger — plan: docs/superpowers/plans/2026-09-04-channel-discovery.md
Адаптация процесса: проект НЕ git-репозиторий (рабочее дерево C:\telbase, деплой docker compose). Адаптация процесса: проект НЕ git-репозиторий (рабочее дерево C:\telbase, деплой docker compose).
Вместо коммитов фиксируем затронутые файлы и результат проверок; вместо git-диффов ревьюер читает Вместо коммитов фиксируем затронутые файлы и результат проверок; вместо git-диффов ревьюер читает
файлы из brief. Рабочая папка плана: .superpowers/sdd/channel-discovery/ файлы из brief. Рабочая папка плана: .superpowers/sdd/channel-discovery/
Pre-flight правки плана (сделаны контроллером до старта): Pre-flight правки плана (сделаны контроллером до старта):
- Task 3: добавлены недостающие интерфейсы delete_candidate() и advance_search() (Task 6 на них ссылается). - Task 3: добавлены недостающие интерфейсы delete_candidate() и advance_search() (Task 6 на них ссылается).
- Task 6: унифицированы метки недоступной истории (закрытая группа/канал — история скрыта; канал — история недоступна). - Task 6: унифицированы метки недоступной истории (закрытая группа/канал — история скрыта; канал — история недоступна).
- Task 5: уточнена detect_lang_ru (>=0.15 -> True, <=0.03 -> False, иначе None). - Task 5: уточнена detect_lang_ru (>=0.15 -> True, <=0.03 -> False, иначе None).
Состояние задач: Состояние задач:
- Task 1: complete (db.py, constants.py, settings_routes.py; review clean; minor: min<=max пауз не проверяется — кандидат в UI-задачу) - Task 1: complete (db.py, constants.py, settings_routes.py; review clean; minor: min<=max пауз не проверяется — кандидат в UI-задачу)
- Task 2: complete (ban_guard.py; review clean; minors: wait_join_delay не покрыт живым прогоном, min<=max не enforced, защитные `or 0`) - Task 2: complete (ban_guard.py; review clean; minors: wait_join_delay не покрыт живым прогоном, min<=max не enforced, защитные `or 0`)
- Task 3: complete (discovery.py; review clean; контракт: внешние dict camelCase, set_candidate_status только new/review, delete_task чистит лог; minors: идемпотентность mark_rejected, сортировка лога) - Task 3: complete (discovery.py; review clean; контракт: внешние dict camelCase, set_candidate_status только new/review, delete_task чистит лог; minors: идемпотентность mark_rejected, сортировка лога)
- Task 4: complete (telegram.py discovery_* + add_dialog_monitored; review clean; fix round 2: форум читает >=3 сообщ./тема; контракт: join без паузы, discovery_read c topic_id/topic_title) - Task 4: complete (telegram.py discovery_* + add_dialog_monitored; review clean; fix round 2: форум читает >=3 сообщ./тема; контракт: join без паузы, discovery_read c topic_id/topic_title)
- Task 5: complete (discovery_eval.py; review clean; семантика: topic "main" для None, ИИ-ветка дополнительно проверяет наличие ключа провайдера) - Task 5: complete (discovery_eval.py; review clean; семантика: topic "main" для None, ИИ-ветка дополнительно проверяет наличие ключа провайдера)
- Task 6: complete (discovery_worker.py + main.py loop; review clean; deferred minor: авто-join без лимита ретраев — риск застревания конвейера на битом кандидате, решить в финале/E2E) - Task 6: complete (discovery_worker.py + main.py loop; review clean; deferred minor: авто-join без лимита ретраев — риск застревания конвейера на битом кандидате, решить в финале/E2E)
- Task 7: complete (discovery_routes.py + main.py; review clean; deferred minors: двойной reject не идемпотентен, join ранее отклонённого оставляет запись в blacklist — решить в финальной волне) - Task 7: complete (discovery_routes.py + main.py; review clean; deferred minors: двойной reject не идемпотентен, join ранее отклонённого оставляет запись в blacklist — решить в финальной волне)
- Task 8: complete (store.js discovery-функции, ChannelsView сегмент, DiscoveryView каркас+мастер; review clean) - Task 8: complete (store.js discovery-функции, ChannelsView сегмент, DiscoveryView каркас+мастер; review clean)
- Task 9: complete (DiscoveryView табы/кандидаты/ЧС/квоты, Icon users/megaphone/list, settings discPaused в _PUBLIC_BOOL; review clean; minors: прямой api-импорт в вью, discCandidateStatus мёртвое, resetLocal не чистит discCounts) - Task 9: complete (DiscoveryView табы/кандидаты/ЧС/квоты, Icon users/megaphone/list, settings discPaused в _PUBLIC_BOOL; review clean; minors: прямой api-импорт в вью, discCandidateStatus мёртвое, resetLocal не чистит discCounts)
- Task 10: complete (ТЗ 4.9 + сборка/health; E2E с живым аккаунтом — за пользователем, шаги в task-10-report.md) - Task 10: complete (ТЗ 4.9 + сборка/health; E2E с живым аккаунтом — за пользователем, шаги в task-10-report.md)
- Фикс-волна (I1I4/M1M5/m1m6): complete (final-fix-report.md; smoke зелёный) - Фикс-волна (I1I4/M1M5/m1m6): complete (final-fix-report.md; smoke зелёный)
- Scoped-ревью фикс-волны: complete (final-review-report.md) — 2 Important + 4 Minor - Scoped-ревью фикс-волны: complete (final-review-report.md) — 2 Important + 4 Minor
закрыты правками в discovery_worker/ban_guard/discovery_routes/settings_routes; закрыты правками в discovery_worker/ban_guard/discovery_routes/settings_routes;
повторный smoke A–E зелёный; образ пересобран, контейнер поднят, API 200. повторный smoke A–E зелёный; образ пересобран, контейнер поднят, API 200.
Все 10 задач + фикс-волна выполнены, ревью чистое. Осталось: живой E2E с Telegram-аккаунтом (шаги в task-10-report.md). Все 10 задач + фикс-волна выполнены, ревью чистое. Осталось: живой E2E с Telegram-аккаунтом (шаги в task-10-report.md).
@@ -1,91 +1,91 @@
### Task 1: Схема БД и настройки по умолчанию ### Task 1: Схема БД и настройки по умолчанию
**Files:** **Files:**
- Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`) - Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`)
- Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`) - Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`)
- Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`) - Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`)
**Interfaces:** **Interfaces:**
- Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40). - Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40).
- [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`) - [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`)
```sql ```sql
CREATE TABLE IF NOT EXISTS disc_tasks ( CREATE TABLE IF NOT EXISTS disc_tasks (
id VARCHAR PRIMARY KEY, id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL, name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '', description VARCHAR NOT NULL DEFAULT '',
keywords VARCHAR NOT NULL DEFAULT '[]', keywords VARCHAR NOT NULL DEFAULT '[]',
min_subscribers INTEGER NOT NULL DEFAULT 0, min_subscribers INTEGER NOT NULL DEFAULT 0,
lang VARCHAR NOT NULL DEFAULT 'ru', lang VARCHAR NOT NULL DEFAULT 'ru',
threshold INTEGER NOT NULL DEFAULT 40, threshold INTEGER NOT NULL DEFAULT 40,
sample_size INTEGER NOT NULL DEFAULT 10, sample_size INTEGER NOT NULL DEFAULT 10,
plan_joins INTEGER NOT NULL DEFAULT 1, plan_joins INTEGER NOT NULL DEFAULT 1,
auto_join BOOLEAN NOT NULL DEFAULT FALSE, auto_join BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
search_idx INTEGER NOT NULL DEFAULT 0, search_idx INTEGER NOT NULL DEFAULT 0,
search_done BOOLEAN NOT NULL DEFAULT FALSE, search_done BOOLEAN NOT NULL DEFAULT FALSE,
found INTEGER NOT NULL DEFAULT 0, found INTEGER NOT NULL DEFAULT 0,
evaluated INTEGER NOT NULL DEFAULT 0, evaluated INTEGER NOT NULL DEFAULT 0,
joined INTEGER NOT NULL DEFAULT 0, joined INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL updated_at BIGINT NOT NULL
); );
CREATE TABLE IF NOT EXISTS disc_candidates ( CREATE TABLE IF NOT EXISTS disc_candidates (
dialog_id VARCHAR PRIMARY KEY, dialog_id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL, task_id VARCHAR NOT NULL,
name VARCHAR NOT NULL DEFAULT '', name VARCHAR NOT NULL DEFAULT '',
username VARCHAR NOT NULL DEFAULT '', username VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
hue VARCHAR NOT NULL DEFAULT '#666', hue VARCHAR NOT NULL DEFAULT '#666',
participants INTEGER, participants INTEGER,
lang_ru BOOLEAN, lang_ru BOOLEAN,
marks VARCHAR NOT NULL DEFAULT '[]', marks VARCHAR NOT NULL DEFAULT '[]',
topics VARCHAR NOT NULL DEFAULT '[]', topics VARCHAR NOT NULL DEFAULT '[]',
fit_ratio DOUBLE, fit_ratio DOUBLE,
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
auto_joined BOOLEAN NOT NULL DEFAULT FALSE, auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL updated_at BIGINT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status); CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
CREATE TABLE IF NOT EXISTS disc_blacklist ( CREATE TABLE IF NOT EXISTS disc_blacklist (
dialog_id VARCHAR PRIMARY KEY, dialog_id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL DEFAULT '', name VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '', reason VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
); );
CREATE TABLE IF NOT EXISTS disc_log ( CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY, id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL, task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
text VARCHAR NOT NULL DEFAULT '', text VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at); CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
``` ```
- [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`** - [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`**
```python ```python
# поиск каналов (Discovery) # поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий) "discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями "discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница "discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке "discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений "discEvalThreshold": 40, # % подходящих сообщений
``` ```
- [ ] **Step 3: Открыть настройки наружу в `settings_routes.py`** - [ ] **Step 3: Открыть настройки наружу в `settings_routes.py`**
В `_PUBLIC_INT` добавить `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. В `patch_settings` наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100. В `_PUBLIC_INT` добавить `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. В `patch_settings` наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.
- [ ] **Step 4: Проверить** - [ ] **Step 4: Проверить**
```bash ```bash
docker compose build app && docker compose up -d app docker compose build app && docker compose up -d app
``` ```
Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок. Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок.
--- ---
@@ -1,91 +1,91 @@
# Task 1 — Отчёт: Схема БД и настройки по умолчанию (Discovery) # Task 1 — Отчёт: Схема БД и настройки по умолчанию (Discovery)
Статус: **DONE** Статус: **DONE**
## Что сделано по шагам ## Что сделано по шагам
### Step 1: Таблицы в `_SCHEMA` (`backend/app/db.py`) ### Step 1: Таблицы в `_SCHEMA` (`backend/app/db.py`)
Добавлены перед таблицей `settings` (текст SQL — дословно из брифа): Добавлены перед таблицей `settings` (текст SQL — дословно из брифа):
- `disc_tasks` — задачи поиска каналов (статус, прогресс, план); - `disc_tasks` — задачи поиска каналов (статус, прогресс, план);
- `disc_candidates` — найденные кандидаты (оценки, статус); - `disc_candidates` — найденные кандидаты (оценки, статус);
- `disc_blacklist` — чёрный список; - `disc_blacklist` — чёрный список;
- `disc_log` — лог событий по задаче; - `disc_log` — лог событий по задаче;
- индексы `idx_disc_cand_task` (candidates: task_id, status) и `idx_disc_log_task` (log: task_id, created_at). - индексы `idx_disc_cand_task` (candidates: task_id, status) и `idx_disc_log_task` (log: task_id, created_at).
> ⚠️ ВАЖНО (поймано при проверке): `_SCHEMA` исполняется по фрагментам через `split(";")`, поэтому в `--`-комментариях над таблицами не должно быть точки с запятой. Первая версия комментария содержала «;» внутри строки комментария — приложение падало на старте (`Parser Error: syntax error at or near "столбцы"`). Исправлено переформулировкой комментария без `;`. Inline-комментарии внутри строк `CREATE TABLE` (из брифа, напр. `-- draft|running|...`) безопасны — в них `;` нет. > ⚠️ ВАЖНО (поймано при проверке): `_SCHEMA` исполняется по фрагментам через `split(";")`, поэтому в `--`-комментариях над таблицами не должно быть точки с запятой. Первая версия комментария содержала «;» внутри строки комментария — приложение падало на старте (`Parser Error: syntax error at or near "столбцы"`). Исправлено переформулировкой комментария без `;`. Inline-комментарии внутри строк `CREATE TABLE` (из брифа, напр. `-- draft|running|...`) безопасны — в них `;` нет.
### Step 2: Настройки в `DEFAULT_SETTINGS` (`backend/app/constants.py`) ### Step 2: Настройки в `DEFAULT_SETTINGS` (`backend/app/constants.py`)
Добавлены в конец словаря (дословно из брифа): Добавлены в конец словаря (дословно из брифа):
```python ```python
# поиск каналов (Discovery) # поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий) "discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями "discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница "discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке "discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений "discEvalThreshold": 40, # % подходящих сообщений
``` ```
### Step 3: Публичные ключи и клампы (`backend/app/routers/settings_routes.py`) ### Step 3: Публичные ключи и клампы (`backend/app/routers/settings_routes.py`)
- В `_PUBLIC_INT` добавлены: `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. - В `_PUBLIC_INT` добавлены: `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`.
- В `patch_settings` (по образцу существующих клампов `archiveAfterDays`/`minLen`): - В `patch_settings` (по образцу существующих клампов `archiveAfterDays`/`minLen`):
- `discJoinLimit` → 1..200; - `discJoinLimit` → 1..200;
- `discJoinDelayMin` / `discJoinDelayMax` → 5..600; - `discJoinDelayMin` / `discJoinDelayMax` → 5..600;
- `discEvalSample` → 3..30; - `discEvalSample` → 3..30;
- `discEvalThreshold` → 1..100. - `discEvalThreshold` → 1..100.
### Step 4: Проверка ### Step 4: Проверка
Выполнена (вывод ниже). Выполнена (вывод ниже).
## Изменённые файлы ## Изменённые файлы
- `backend/app/db.py` - `backend/app/db.py`
- `backend/app/constants.py` - `backend/app/constants.py`
- `backend/app/routers/settings_routes.py` - `backend/app/routers/settings_routes.py`
## Вывод проверок ## Вывод проверок
### 1. `py_compile` ### 1. `py_compile`
``` ```
$ cd /c/telbase && python -m py_compile backend/app/db.py backend/app/constants.py backend/app/routers/settings_routes.py $ cd /c/telbase && python -m py_compile backend/app/db.py backend/app/constants.py backend/app/routers/settings_routes.py
PY_COMPILE_OK # без ошибок PY_COMPILE_OK # без ошибок
``` ```
### 2. Пересборка и запуск контейнера ### 2. Пересборка и запуск контейнера
``` ```
$ docker compose build app && docker compose up -d app $ docker compose build app && docker compose up -d app
[+] build 1/1 ✔ Image telbase-app Built (первый запуск упал — см. Concern/Фикс) [+] build 1/1 ✔ Image telbase-app Built (первый запуск упал — см. Concern/Фикс)
[+] up 3/3 [+] up 3/3
✔ Container leadradar-ml Running ✔ Container leadradar-ml Running
✔ Container leadradar-minio Running ✔ Container leadradar-minio Running
✔ Container leadradar Started ✔ Container leadradar Started
``` ```
Логи контейнера после фикса: Логи контейнера после фикса:
``` ```
INFO: Application startup complete. INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Ctrl+C to quit) INFO: Uvicorn running on http://0.0.0.0:8000 (Ctrl+C to quit)
``` ```
### 3. API ### 3. API
``` ```
POST /api/auth/login {"login":"admin","password":"admin"} → login status: 200 POST /api/auth/login {"login":"admin","password":"admin"} → login status: 200
GET /api/settings → GET /api/settings →
settings: {"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70, settings: {"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40} "discEvalSample": 10, "discEvalThreshold": 40}
``` ```
Все пять ключей присутствуют и равны значениям по умолчанию. ✔ Все пять ключей присутствуют и равны значениям по умолчанию. ✔
### Дополнительно ### Дополнительно
- Попытка прямой проверки таблиц в DuckDB вторым процессом - Попытка прямой проверки таблиц в DuckDB вторым процессом
(`duckdb.connect(..., read_only=True)`) не удалась ожидаемо: DuckDB — одна запись, (`duckdb.connect(..., read_only=True)`) не удалась ожидаемо: DuckDB — одна запись,
лок держит процесс приложения (`Conflicting lock is held... PID 1`). Косвенное лок держит процесс приложения (`Conflicting lock is held... PID 1`). Косвенное
подтверждение: `_SCHEMA` исполняется в `store.init()` при старте и падает loudly подтверждение: `_SCHEMA` исполняется в `store.init()` при старте и падает loudly
(первый запуск упал с Parser Error — это и выявило баг), а финальный старт чистый. (первый запуск упал с Parser Error — это и выявило баг), а финальный старт чистый.
## Concerns ## Concerns
1. **`;` в `--`-комментариях `_SCHEMA` опасен** — пайплайн `split(";")` режет схему 1. **`;` в `--`-комментариях `_SCHEMA` опасен** — пайплайн `split(";")` режет схему
посередине комментария. В добавленном блоке таких мест больше нет, но при будущих посередине комментария. В добавленном блоке таких мест больше нет, но при будущих
правках схемы стоит избегать `;` в комментариях. правках схемы стоит избегать `;` в комментариях.
2. Клампы `discJoinDelayMin`/`discJoinDelayMax` независимы (оба 5..600) — нет 2. Клампы `discJoinDelayMin`/`discJoinDelayMax` независимы (оба 5..600) — нет
перекрёстной проверки `min <= max` (в брифе её не требовалось). перекрёстной проверки `min <= max` (в брифе её не требовалось).
3. Прямой SQL-запрос к таблицам извне невозможен при запущенном приложении (лок 3. Прямой SQL-запрос к таблицам извне невозможен при запущенном приложении (лок
DuckDB); фактическое наличие таблиц подтверждено чистым стартом и прохождением DuckDB); фактическое наличие таблиц подтверждено чистым стартом и прохождением
всей схемы в `store.init()`. всей схемы в `store.init()`.
@@ -1,22 +1,22 @@
### Task 10: ТЗ, сборка и end-to-end проверка ### Task 10: ТЗ, сборка и end-to-end проверка
**Files:** **Files:**
- Modify: `ТЗ.md` (раздел «Поиск и подключение каналов») - Modify: `ТЗ.md` (раздел «Поиск и подключение каналов»)
- [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах». - [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
- [ ] **Step 2: Сборка и рестарт**: - [ ] **Step 2: Сборка и рестарт**:
```bash ```bash
docker compose build app && docker compose up -d app docker compose build app && docker compose up -d app
cd frontend && npm run build cd frontend && npm run build
``` ```
- [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**: - [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**:
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить. 1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются. 2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
3. Открыть кандидата: метки, участники, fit «X из N», темы форума. 3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки. 4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
5. «Отклонить» → уходит в чёрный список; повторно не находится. 5. «Отклонить» → уходит в чёрный список; повторно не находится.
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита. 6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
--- ---
@@ -1,47 +1,47 @@
# Task 10 — Отчёт: ТЗ, сборка и health-проверка # Task 10 — Отчёт: ТЗ, сборка и health-проверка
## Статус ## Статус
Выполнено. ТЗ дополнено разделом 4.9; docker-образ собран и контейнер перезапущен; фронтенд собран; py_compile всех файлов Discovery — OK; health/settings проверены по HTTP (200). Живой E2E с кандидатами/вступлениями не выполнялся (нужен реальный Telegram-аккаунт) — ручные шаги перенесены в «Осталось для ручной проверки». Выполнено. ТЗ дополнено разделом 4.9; docker-образ собран и контейнер перезапущен; фронтенд собран; py_compile всех файлов Discovery — OK; health/settings проверены по HTTP (200). Живой E2E с кандидатами/вступлениями не выполнялся (нужен реальный Telegram-аккаунт) — ручные шаги перенесены в «Осталось для ручной проверки».
## Файлы ## Файлы
- Изменён: `ТЗ.md` — добавлен подраздел `### **4.9. Поиск и подключение каналов (Discovery)**` (11 пунктов) между `4.8` и «5. Спецификация пайплайна обработки данных». - Изменён: `ТЗ.md` — добавлен подраздел `### **4.9. Поиск и подключение каналов (Discovery)**` (11 пунктов) между `4.8` и «5. Спецификация пайплайна обработки данных».
## Что сделано ## Что сделано
### Step 1: Раздел ТЗ (4.9) ### Step 1: Раздел ТЗ (4.9)
Структура файла: функциональные требования — секция 4.x (4.1–4.8), поэтому Discovery добавлен подразделом **4.9** (перед «5. Спецификация пайплайна»), в стиле остальных разделов (`### **4.N. …**` + маркированный список `> *`). Покрыто по спеке: Структура файла: функциональные требования — секция 4.x (4.1–4.8), поэтому Discovery добавлен подразделом **4.9** (перед «5. Спецификация пайплайна»), в стиле остальных разделов (`### **4.N. …**` + маркированный список `> *`). Покрыто по спеке:
- Подвкладка **«Поиск»** на экране «Каналы»; задача поиска: описание цели → генерация поисковых ключей ИИ (редактируются до старта) → **каскад фильтров** по нарастающей стоимости (поиск/дедупликация → «мы не состоим» → число участников → язык → оценка содержимого). - Подвкладка **«Поиск»** на экране «Каналы»; задача поиска: описание цели → генерация поисковых ключей ИИ (редактируются до старта) → **каскад фильтров** по нарастающей стоимости (поиск/дедупликация → «мы не состоим» → число участников → язык → оценка содержимого).
- **Глобальное правило «мы не состоим»** — безусловное, для всех задач и всех этапов (поиск → оценка → вступление), повторная проверка перед join'ом. - **Глобальное правило «мы не состоим»** — безусловное, для всех задач и всех этапов (поиск → оценка → вступление), повторная проверка перед join'ом.
- **Метки**: «закрытая группа/канал», «форум», «не прочитано», «участники не подтверждены», «язык не подтверждён», «мало сообщений», «есть проходные темы»; «не подтверждено» = сигнал человеку, не пропуск. - **Метки**: «закрытая группа/канал», «форум», «не прочитано», «участники не подтверждены», «язык не подтверждён», «мало сообщений», «есть проходные темы»; «не подтверждено» = сигнал человеку, не пропуск.
- Оценка содержимого с **профилем задачи** (без карточек/обучения ML); каналы/открытые группы — выборка до sampleSize, доля ≥ порога; открытая группа без чтения — на рассмотрение с меткой. - Оценка содержимого с **профилем задачи** (без карточек/обучения ML); каналы/открытые группы — выборка до sampleSize, доля ≥ порога; открытая группа без чтения — на рассмотрение с меткой.
- **Оценка по темам (форумы):** тема «подходит X из N», группа подходит при ≥1 проходной теме, превью тем; ≥3 содержательных сообщений для оценки, иначе метка «мало сообщений»; закрытые группы — сразу на рассмотрение с меткой. - **Оценка по темам (форумы):** тема «подходит X из N», группа подходит при ≥1 проходной теме, превью тем; ≥3 содержательных сообщений для оценки, иначе метка «мало сообщений»; закрытые группы — сразу на рассмотрение с меткой.
- **Review/join/reject + чёрный список**: «Вступить и мониторить» (monitor=1 + догон ~10 сообщений), «Отклонить» → чёрный список (исключение во всех задачах, снимается вручную), закрытые группы — ссылка `t.me/<username>` + авто-замечание при синхронизации. - **Review/join/reject + чёрный список**: «Вступить и мониторить» (monitor=1 + догон ~10 сообщений), «Отклонить» → чёрный список (исключение во всех задачах, снимается вручную), закрытые группы — ссылка `t.me/<username>` + авто-замечание при синхронизации.
- **План задач и правило создания:** план 1–50; сумма планов активных задач (не `done/failed`) ≤ суточного лимита (по умолчанию 50); у активной задачи план не увеличить сверх свободного бюджета. - **План задач и правило создания:** план 1–50; сумма планов активных задач (не `done/failed`) ≤ суточного лимита (по умолчанию 50); у активной задачи план не увеличить сверх свободного бюджета.
- **Авто-вступление и квоты:** `autoJoin` на задачу; случайная пауза **5070 с**, по одному действию; лимит **50 авто-вступлений/сутки общий на все задачи**; ручные — без квот; задача «выполнена» по плану, упор в бюджет — продолжение на следующий день. - **Авто-вступление и квоты:** `autoJoin` на задачу; случайная пауза **5070 с**, по одному действию; лимит **50 авто-вступлений/сутки общий на все задачи**; ручные — без квот; задача «выполнена» по плану, упор в бюджет — продолжение на следующий день.
- **Анти-бан (BanGuard):** мягкие паузы поиска/чтения, `FloodWaitError` → пауза + остановка авто-вступлений до следующего дня, общий «стоп-кран»; лимит/интервалы — настройки UI. - **Анти-бан (BanGuard):** мягкие паузы поиска/чтения, `FloodWaitError` → пауза + остановка авто-вступлений до следующего дня, общий «стоп-кран»; лимит/интервалы — настройки UI.
### Step 2: Сборка и рестарт ### Step 2: Сборка и рестарт
- `docker compose build app && docker compose up -d app` — образ `telbase-app` собран, контейнер `leadradar` пересоздан и поднят; `leadradar-ml`/`leadradar-minio` — running. - `docker compose build app && docker compose up -d app` — образ `telbase-app` собран, контейнер `leadradar` пересоздан и поднят; `leadradar-ml`/`leadradar-minio` — running.
- `cd frontend && npm run build` — без ошибок. - `cd frontend && npm run build` — без ошибок.
- `python -m py_compile …` (все файлы брифa) — без ошибок. - `python -m py_compile …` (все файлы брифa) — без ошибок.
## Вывод проверок ## Вывод проверок
1. `docker compose build app && docker compose up -d app` → image built, контейнер Started; лог старта чистый (startup complete, без traceback). 1. `docker compose build app && docker compose up -d app` → image built, контейнер Started; лог старта чистый (startup complete, без traceback).
2. `cd /c/telbase/frontend && npm run build``✓ built in 1.35s`, 42 modules transformed, ошибок нет (сборка в Dockerfile — та же: 42 modules). 2. `cd /c/telbase/frontend && npm run build``✓ built in 1.35s`, 42 modules transformed, ошибок нет (сборка в Dockerfile — та же: 42 modules).
3. `python -m py_compile backend/app/services/discovery.py discovery_eval.py discovery_worker.py ban_guard.py telegram.py backend/app/routers/discovery_routes.py settings_routes.py backend/app/main.py` → OK. 3. `python -m py_compile backend/app/services/discovery.py discovery_eval.py discovery_worker.py ban_guard.py telegram.py backend/app/routers/discovery_routes.py settings_routes.py backend/app/main.py` → OK.
4. `GET /api/health`**200**. 4. `GET /api/health`**200**.
5. `POST /api/auth/login` (admin/admin) → 200; `GET /api/settings`**200**, discovery-ключи присутствуют: `discJoinLimit: 50`, `discJoinDelayMin: 50`, `discJoinDelayMax: 70` (int-группа `_PUBLIC_INT`), `discPaused: false` (bool, отдаётся из `_PUBLIC_BOOL`); дополнительно `discEvalSample: 10`, `discEvalThreshold: 40`. 5. `POST /api/auth/login` (admin/admin) → 200; `GET /api/settings`**200**, discovery-ключи присутствуют: `discJoinLimit: 50`, `discJoinDelayMin: 50`, `discJoinDelayMax: 70` (int-группа `_PUBLIC_INT`), `discPaused: false` (bool, отдаётся из `_PUBLIC_BOOL`); дополнительно `discEvalSample: 10`, `discEvalThreshold: 40`.
6. Smoke API: `GET /api/discovery/tasks` → 200 `{"items":[]}`, `GET /api/discovery/blacklist` → 200 `{"items":[]}` — роутер Discovery включён, БД-таблицы созданы. 6. Smoke API: `GET /api/discovery/tasks` → 200 `{"items":[]}`, `GET /api/discovery/blacklist` → 200 `{"items":[]}` — роутер Discovery включён, БД-таблицы созданы.
## Осталось для ручной проверки (E2E из брифа Step 3 — нужен подключённый Telegram-аккаунт) ## Осталось для ручной проверки (E2E из брифа Step 3 — нужен подключённый Telegram-аккаунт)
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить. 1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
2. Дождаться кандидатов; убедиться, что текущие подписки и отклонённые не появляются (правило «мы не состоим» + чёрный список). 2. Дождаться кандидатов; убедиться, что текущие подписки и отклонённые не появляются (правило «мы не состоим» + чёрный список).
3. Открыть кандидата: метки, участники, fit «X из N», темы форума. 3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
4. «Вступить и мониторить» → источник в «Каналах» (monitor on) и начинает давать карточки. 4. «Вступить и мониторить» → источник в «Каналах» (monitor on) и начинает давать карточки.
5. «Отклонить» → источник в чёрном списке; повторно не находится. 5. «Отклонить» → источник в чёрном списке; повторно не находится.
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита (50). 6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита (50).
## Concerns ## Concerns
1. ТЗ-раздел написан кратко по спеке — детальные значения (таблицы полей задачи/БД, схемы API) сознательно не дублируются: в ТЗ это функциональный обзор, а точные контракты зафиксированы в дизайн-доке (ссылки на параметры `discJoinLimit`, `sampleSize`, `autoJoin` и т.п. даны). 1. ТЗ-раздел написан кратко по спеке — детальные значения (таблицы полей задачи/БД, схемы API) сознательно не дублируются: в ТЗ это функциональный обзор, а точные контракты зафиксированы в дизайн-доке (ссылки на параметры `discJoinLimit`, `sampleSize`, `autoJoin` и т.п. даны).
2. «На рассмотрение»/вступление/чёрный список проверены только на уровне API-контракта (пустые списки 200) и логов запуска — поведение с живым Telegram-аккаунтом (поиск реально возвращает кандидатов, join проходит) остаётся за ручной проверкой пользователя. 2. «На рассмотрение»/вступление/чёрный список проверены только на уровне API-контракта (пустые списки 200) и логов запуска — поведение с живым Telegram-аккаунтом (поиск реально возвращает кандидатов, join проходит) остаётся за ручной проверкой пользователя.
3. `discPaused` — рантайм-настройка без дефолта в `DEFAULT_SETTINGS`; в GET /api/settings присутствует как `false` (bool), что подтверждено. 3. `discPaused` — рантайм-настройка без дефолта в `DEFAULT_SETTINGS`; в GET /api/settings присутствует как `false` (bool), что подтверждено.
4. `progress.md` не трогался. 4. `progress.md` не трогался.
@@ -1,33 +1,33 @@
### Task 2: BanGuard (квоты, паузы, flood) ### Task 2: BanGuard (квоты, паузы, flood)
**Files:** **Files:**
- Create: `backend/app/services/ban_guard.py` - Create: `backend/app/services/ban_guard.py`
**Interfaces:** **Interfaces:**
- Consumes: `store`, настройки из Task 1. - Consumes: `store`, настройки из Task 1.
- Produces: - Produces:
- `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`). - `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`).
- `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы. - `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
- `async def wait_join_delay() -> None``asyncio.sleep(random.uniform(min, max))`. - `async def wait_join_delay() -> None``asyncio.sleep(random.uniform(min, max))`.
- `def note_flood() -> None``store.set_setting("discFloodDay", <start_of_day_ms>)`. - `def note_flood() -> None``store.set_setting("discFloodDay", <start_of_day_ms>)`.
- `def flood_today() -> bool` - `def flood_today() -> bool`
- `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`) - `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`)
- `def search_pause() -> float``random.uniform(2.0, 4.0)`. - `def search_pause() -> float``random.uniform(2.0, 4.0)`.
- [ ] **Step 1: Реализовать модуль** (~60 строк; начало суток — UTC: `datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)` → ms). - [ ] **Step 1: Реализовать модуль** (~60 строк; начало суток — UTC: `datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
- [ ] **Step 2: Проверить на временной БД в контейнере** - [ ] **Step 2: Проверить на временной БД в контейнере**
```bash ```bash
docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c " docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init() from app.db import store; store.init()
from app.services import ban_guard as bg from app.services import ban_guard as bg
assert bg.can_auto_join() is True assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0 assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK') print('BANGUARD OK')
" "
``` ```
--- ---
@@ -1,84 +1,84 @@
# Task 2 — Отчёт: BanGuard (квоты, паузы, flood) # Task 2 — Отчёт: BanGuard (квоты, паузы, flood)
Статус: **DONE** Статус: **DONE**
## Что сделано по шагам ## Что сделано по шагам
### Step 1: Модуль `backend/app/services/ban_guard.py` (создан) ### Step 1: Модуль `backend/app/services/ban_guard.py` (создан)
Реализован по интерфейс-спеке брифа (в брифе «дословного» кода нет — только сигнатуры Реализован по интерфейс-спеке брифа (в брифе «дословного» кода нет — только сигнатуры
и поведение, см. Concerns #1): и поведение, см. Concerns #1):
- `joins_today_auto() -> int``count(*)` из `disc_log` по `event='join_auto'` - `joins_today_auto() -> int``count(*)` из `disc_log` по `event='join_auto'`
с `created_at >= начало текущих UTC-суток` (`datetime.now(timezone.utc) с `created_at >= начало текущих UTC-суток` (`datetime.now(timezone.utc)
.replace(hour=0,minute=0,second=0,microsecond=0)` → ms). .replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
- `can_auto_join() -> bool``joins_today_auto() < discJoinLimit` И `not flood_today()` - `can_auto_join() -> bool``joins_today_auto() < discJoinLimit` И `not flood_today()`
И `not global_paused()`. И `not global_paused()`.
- `async wait_join_delay() -> None` — `asyncio.sleep(random.uniform(discJoinDelayMin, - `async wait_join_delay() -> None` — `asyncio.sleep(random.uniform(discJoinDelayMin,
discJoinDelayMax))` (значения из `store.get_setting`). discJoinDelayMax))` (значения из `store.get_setting`).
- `note_flood() -> None` — `store.set_setting("discFloodDay", <start_of_day_ms>)`. - `note_flood() -> None` — `store.set_setting("discFloodDay", <start_of_day_ms>)`.
- `flood_today() -> bool` — `discFloodDay == start_of_day_ms` (вчерашний флуд-день - `flood_today() -> bool` — `discFloodDay == start_of_day_ms` (вчерашний флуд-день
автоматически «протухает» в полночь UTC). автоматически «протухает» в полночь UTC).
- `global_paused() -> bool` / `set_global_pause(v: bool) -> None` — setting `discPaused`. - `global_paused() -> bool` / `set_global_pause(v: bool) -> None` — setting `discPaused`.
- `search_pause() -> float` — `random.uniform(2.0, 4.0)`. - `search_pause() -> float` — `random.uniform(2.0, 4.0)`.
Детали реализации: Детали реализации:
- Хелпер `_start_of_day_ms()` общий для квоты, флуда и паузы. - Хелпер `_start_of_day_ms()` общий для квоты, флуда и паузы.
- Ключи настроек вынесены в модульные константы (`_KEY_*`) — в коде нет «голых» строк. - Ключи настроек вынесены в модульные константы (`_KEY_*`) — в коде нет «голых» строк.
- `discPaused`/`discFloodDay` не в `DEFAULT_SETTINGS` (рантайм-настройки): `get_setting` - `discPaused`/`discFloodDay` не в `DEFAULT_SETTINGS` (рантайм-настройки): `get_setting`
возвращает `None`, который трактуется как «не взведено» (`False`/`0`). возвращает `None`, который трактуется как «не взведено» (`False`/`0`).
- Стиль модуля — как в соседних сервисах: `from ..db import store`, русские докстринги, - Стиль модуля — как в соседних сервисах: `from ..db import store`, русские докстринги,
`from __future__ import annotations`. `from __future__ import annotations`.
### Step 2: Проверка на временной БД в контейнере ### Step 2: Проверка на временной БД в контейнере
Первая попытка упала: `ImportError: cannot import name 'ban_guard'` — код копируется в Первая попытка упала: `ImportError: cannot import name 'ban_guard'` — код копируется в
образ при сборке (`backend/Dockerfile`: `COPY backend/app ./app`), исходники не образ при сборке (`backend/Dockerfile`: `COPY backend/app ./app`), исходники не
монтируются, а образ был собран до создания файла. Выполнена пересборка монтируются, а образ был собран до создания файла. Выполнена пересборка
`docker compose build app` (2.6s, слой кода — единственный не из кэша), затем команда `docker compose build app` (2.6s, слой кода — единственный не из кэша), затем команда
из брифа прошла (вывод ниже). из брифа прошла (вывод ниже).
## Изменённые файлы ## Изменённые файлы
- `backend/app/services/ban_guard.py` (создан) - `backend/app/services/ban_guard.py` (создан)
## Вывод проверок ## Вывод проверок
### 1. `py_compile` ### 1. `py_compile`
``` ```
$ cd /c/telbase && python -m py_compile backend/app/services/ban_guard.py $ cd /c/telbase && python -m py_compile backend/app/services/ban_guard.py
COMPILE_OK # без ошибок COMPILE_OK # без ошибок
``` ```
### 2. Временная БД в контейнере (команда из брифа Step 2) ### 2. Временная БД в контейнере (команда из брифа Step 2)
``` ```
$ docker compose build app # необходимо, т.к. код запекается в образ $ docker compose build app # необходимо, т.к. код запекается в образ
$ docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c " $ docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init() from app.db import store; store.init()
from app.services import ban_guard as bg from app.services import ban_guard as bg
assert bg.can_auto_join() is True assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0 assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK') print('BANGUARD OK')
" "
BANGUARD OK BANGUARD OK
``` ```
Все 4 assert'а из брифа прошли. ✔ Все 4 assert'а из брифа прошли. ✔
### Дополнительно ### Дополнительно
- Статическая проверка (diagnostics Zed): ошибок и предупреждений нет. - Статическая проверка (diagnostics Zed): ошибок и предупреждений нет.
## Concerns ## Concerns
1. **«Весь код — в брифе» — фактически неверно**: в `task-2-brief.md` (и в плане) 1. **«Весь код — в брифе» — фактически неверно**: в `task-2-brief.md` (и в плане)
нет ни одного блока кода модуля, только интерфейс-спека (~10 сигнатур с описанием нет ни одного блока кода модуля, только интерфейс-спека (~10 сигнатур с описанием
поведения) и команда проверки. «Транскрибировать дословно» было нечего; модуль поведения) и команда проверки. «Транскрибировать дословно» было нечего; модуль
реализован по спеке. Очевидных ошибок/противоречий в спеке не нашёл — править было реализован по спеке. Очевидных ошибок/противоречий в спеке не нашёл — править было
нечего. Если планировался эталонный код — его нужно добавить в бриф. нечего. Если планировался эталонный код — его нужно добавить в бриф.
2. `wait_join_delay()` и `search_pause()` не покрыты проверкой Step 2 (оба — случайные 2. `wait_join_delay()` и `search_pause()` не покрыты проверкой Step 2 (оба — случайные
паузы; дефолт паузы вступления 50–70 сек, поэтому в проверку они не входили). паузы; дефолт паузы вступления 50–70 сек, поэтому в проверку они не входили).
Логика тривиальная (`random.uniform` + `asyncio.sleep`), но «живого» прогона нет. Логика тривиальная (`random.uniform` + `asyncio.sleep`), но «живого» прогона нет.
3. `can_auto_join()` при `discJoinLimit <= 0` всегда `False` (осторожная сторона: 3. `can_auto_join()` при `discJoinLimit <= 0` всегда `False` (осторожная сторона:
лимит 0 = «не вступать»). лимит 0 = «не вступать»).
4. Клампы пауз (5..600) независимы — `min > max` теоретически возможно через UI 4. Клампы пауз (5..600) независимы — `min > max` теоретически возможно через UI
(унаследованный concern из Task 1; в `wait_join_delay` тогда диапазон (унаследованный concern из Task 1; в `wait_join_delay` тогда диапазон
«вывернется», но не упадёт). Перекрёстную проверку бриф не требовал. «вывернется», но не упадёт). Перекрёстную проверку бриф не требовал.
5. Для проверок последующих задач нужна пересборка образа после каждого изменения 5. Для проверок последующих задач нужна пересборка образа после каждого изменения
backend-кода (образ не монтирует исходники). backend-кода (образ не монтирует исходники).
@@ -1,29 +1,29 @@
### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог) ### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
**Files:** **Files:**
- Create: `backend/app/services/discovery.py` - Create: `backend/app/services/discovery.py`
**Interfaces:** **Interfaces:**
- Consumes: `store` (таблицы Task 1). - Consumes: `store` (таблицы Task 1).
- Produces (все синхронные): - Produces (все синхронные):
- `list_tasks() -> list[dict]`, `get_task(id) -> dict | None` (keywords — список) - `list_tasks() -> list[dict]`, `get_task(id) -> dict | None` (keywords — список)
- `create_task(payload: dict) -> dict` — валидация: name непустое; `plan_joins` 1..limit; **правило бюджета**: `sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit`, иначе `raise ValueError(...)`. - `create_task(payload: dict) -> dict` — валидация: name непустое; `plan_joins` 1..limit; **правило бюджета**: `sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit`, иначе `raise ValueError(...)`.
- `patch_task(id, patch: dict) -> dict` (name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой) - `patch_task(id, patch: dict) -> dict` (name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой)
- `delete_task(id) -> None` (удалить задачу и её кандидатов) - `delete_task(id) -> None` (удалить задачу и её кандидатов)
- `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused - `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused
- `list_candidates(task_id, status: str | None) -> list[dict]` (декод marks/topics) - `list_candidates(task_id, status: str | None) -> list[dict]` (декод marks/topics)
- `add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None``None`, если: в `dialogs`, в `disc_blacklist`, либо уже есть `disc_candidates` со статусом new/review/joined. Лог `skip` с причиной. - `add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None``None`, если: в `dialogs`, в `disc_blacklist`, либо уже есть `disc_candidates` со статусом new/review/joined. Лог `skip` с причиной.
- `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected - `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected
- `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата - `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата
- `set_candidate_status(dialog_id, status)` + лог - `set_candidate_status(dialog_id, status)` + лог
- `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки) - `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки)
- `advance_search(task_id) -> None``search_idx += 1`; когда индекс >= len(keywords) → `search_done=True` - `advance_search(task_id) -> None``search_idx += 1`; когда индекс >= len(keywords) → `search_done=True`
- `mark_joined(dialog_id, auto: bool)` — статус joined + `bump_counter('joined')` + лог `join_auto`/`join_manual` - `mark_joined(dialog_id, auto: bool)` — статус joined + `bump_counter('joined')` + лог `join_auto`/`join_manual`
- `mark_rejected(dialog_id, reason="")` — статус rejected + `bump_counter('rejected')` + лог `reject` + `add_blacklist` - `mark_rejected(dialog_id, reason="")` — статус rejected + `bump_counter('rejected')` + лог `reject` + `add_blacklist`
- `add_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]` - `add_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]`
- `add_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]` - `add_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]`
- [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами). - [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами).
- [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs``add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None. - [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs``add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None.
--- ---
@@ -1,111 +1,111 @@
# Task 3 — Отчёт: Хранилище Discovery (задачи/кандидаты/чёрный список/лог) # Task 3 — Отчёт: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
## Статус ## Статус
✅ Реализовано и проверено (py_compile + сценарий на временной БД в контейнере). ✅ Реализовано и проверено (py_compile + сценарий на временной БД в контейнере).
## Файлы ## Файлы
- Создан: `backend/app/services/discovery.py` — все функции из брифа (см. «Interfaces» брифа), синхронные, поверх `store` из `db.py`. - Создан: `backend/app/services/discovery.py` — все функции из брифа (см. «Interfaces» брифа), синхронные, поверх `store` из `db.py`.
## Что сделано ## Что сделано
### Step 1: Модуль `backend/app/services/discovery.py` ### Step 1: Модуль `backend/app/services/discovery.py`
Полный набор интерфейсов брифа: Полный набор интерфейсов брифа:
- **Задачи**: `list_tasks()`, `get_task(id)`, `create_task(payload)`, `patch_task(id, patch)`, `delete_task(id)` (задача + её кандидаты + лог), `start_task(id)`, `pause_task(id)`. - **Задачи**: `list_tasks()`, `get_task(id)`, `create_task(payload)`, `patch_task(id, patch)`, `delete_task(id)` (задача + её кандидаты + лог), `start_task(id)`, `pause_task(id)`.
- **Бюджет**: `sum(plan_joins)` задач со статусом `NOT IN ('done','failed')` + новая/увеличенная `plan_joins <= discJoinLimit`; иначе `ValueError` (сообщение с занятой суммой и лимитом). Дополнительно `plan_joins` ограничен `1..discJoinLimit`. Проверка при увеличении `plan_joins` в `patch_task` — с исключением самой задачи из суммы. - **Бюджет**: `sum(plan_joins)` задач со статусом `NOT IN ('done','failed')` + новая/увеличенная `plan_joins <= discJoinLimit`; иначе `ValueError` (сообщение с занятой суммой и лимитом). Дополнительно `plan_joins` ограничен `1..discJoinLimit`. Проверка при увеличении `plan_joins` в `patch_task` — с исключением самой задачи из суммы.
- **Кандидаты**: `list_candidates(task_id, status)`, `add_candidate(...)` (None + лог `skip`, если источник в `dialogs`/`disc_blacklist`/уже есть в `new|review|joined`; успешное добавление инкрементит `found`), `set_candidate(task_id, dialog_id, patch)`, `set_candidate_status(dialog_id, status)`, `delete_candidate(dialog_id)`, `bump_counter(task_id, field, n)` (found/evaluated/joined/rejected), `advance_search(task_id)`. - **Кандидаты**: `list_candidates(task_id, status)`, `add_candidate(...)` (None + лог `skip`, если источник в `dialogs`/`disc_blacklist`/уже есть в `new|review|joined`; успешное добавление инкрементит `found`), `set_candidate(task_id, dialog_id, patch)`, `set_candidate_status(dialog_id, status)`, `delete_candidate(dialog_id)`, `bump_counter(task_id, field, n)` (found/evaluated/joined/rejected), `advance_search(task_id)`.
- **Переходы**: `mark_joined(dialog_id, auto)``joined` + `bump_counter('joined')` + лог `join_auto`/`join_manual` (идемпотентно); `mark_rejected(dialog_id, reason="")``rejected` + счётчик + лог `reject` + `add_blacklist`; при статусе `joined``ValueError` (для 400 в Task 7). - **Переходы**: `mark_joined(dialog_id, auto)``joined` + `bump_counter('joined')` + лог `join_auto`/`join_manual` (идемпотентно); `mark_rejected(dialog_id, reason="")``rejected` + счётчик + лог `reject` + `add_blacklist`; при статусе `joined``ValueError` (для 400 в Task 7).
- **Чёрный список / лог**: `add_blacklist`, `remove_blacklist`, `list_blacklist`, `add_log`, `task_log(limit=100)`. - **Чёрный список / лог**: `add_blacklist`, `remove_blacklist`, `list_blacklist`, `add_log`, `task_log(limit=100)`.
Ключевые решения (задокументированы в докстринге модуля): Ключевые решения (задокументированы в докстринге модуля):
- Все обращения к БД — `store.*` с параметрами; JSON-поля `keywords/marks/topics``json.dumps(..., ensure_ascii=False)`/`json.loads`. - Все обращения к БД — `store.*` с параметрами; JSON-поля `keywords/marks/topics``json.dumps(..., ensure_ascii=False)`/`json.loads`.
- Время — `time.time_ns() // 1_000_000`; id — `store.uid('dt_'/'dl_')`. - Время — `time.time_ns() // 1_000_000`; id — `store.uid('dt_'/'dl_')`.
- Наружные dict-ы — **camelCase** (конвенция границы API проекта, как `projects.py`/`leads.py`): `planJoins`, `sampleSize`, `minSubscribers`, `autoJoin`, `searchIdx`, `searchDone`, `fitRatio`, `autoJoined`, `langRu`, `dialogId``create_task`/`patch_task` на входе принимают и camelCase, и snake_case (нормализация к колонкам БД), поэтому Task 7 может передавать `TaskCreate.model_dump()` напрямую. - Наружные dict-ы — **camelCase** (конвенция границы API проекта, как `projects.py`/`leads.py`): `planJoins`, `sampleSize`, `minSubscribers`, `autoJoin`, `searchIdx`, `searchDone`, `fitRatio`, `autoJoined`, `langRu`, `dialogId``create_task`/`patch_task` на входе принимают и camelCase, и snake_case (нормализация к колонкам БД), поэтому Task 7 может передавать `TaskCreate.model_dump()` напрямую.
- `set_candidate_status` разрешает `new/review` (лог `review`); `joined/rejected` — только через `mark_joined`/`mark_rejected` (там счётчики/чёрный список/лог). - `set_candidate_status` разрешает `new/review` (лог `review`); `joined/rejected` — только через `mark_joined`/`mark_rejected` (там счётчики/чёрный список/лог).
- `start_task`: keywords непустые (иначе `ValueError`); из `done/failed` — сброс прогресса поиска, из `paused` — продолжение без сброса. - `start_task`: keywords непустые (иначе `ValueError`); из `done/failed` — сброс прогресса поиска, из `paused` — продолжение без сброса.
- `advance_search`: `search_idx += 1`; `search_idx >= len(keywords)``search_done = True`. - `advance_search`: `search_idx += 1`; `search_idx >= len(keywords)``search_done = True`.
- `delete_candidate` — идемпотентная (skip-ветки воркера); перезапись «устаревшего» rejected-кандидата новым при `add_candidate` (после `remove_blacklist`), т.к. `dialog_id` — PK. - `delete_candidate` — идемпотентная (skip-ветки воркера); перезапись «устаревшего» rejected-кандидата новым при `add_candidate` (после `remove_blacklist`), т.к. `dialog_id` — PK.
### Step 2: Проверка на временной БД в контейнере ### Step 2: Проверка на временной БД в контейнере
Код запекается в образ, поэтому перед прогоном: `docker compose build app` (кэш — сборка ~3 c). Код запекается в образ, поэтому перед прогоном: `docker compose build app` (кэш — сборка ~3 c).
Команда: Команда:
``` ```
MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \ MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv \
-e LEADRADAR_DATA=/tmp/lr_disc --entrypoint python app /data/task3_check.py -e LEADRADAR_DATA=/tmp/lr_disc --entrypoint python app /data/task3_check.py
``` ```
Сценарий (текст; временный файл `data/task3_check.py`, смонтирован в контейнер как `/data/task3_check.py`; после прогона удалён): Сценарий (текст; временный файл `data/task3_check.py`, смонтирован в контейнер как `/data/task3_check.py`; после прогона удалён):
```python ```python
from app.db import store from app.db import store
from app.services import discovery as d from app.services import discovery as d
store.init() store.init()
# ── 1. Бюджет plan_joins: 25 ок; 30 поверх 25 -> ValueError (лимит 50) ────── # ── 1. Бюджет plan_joins: 25 ок; 30 поверх 25 -> ValueError (лимит 50) ──────
t1 = d.create_task({"name": "Задача A", "planJoins": 25, "keywords": ["fl", "market", "python"]}) t1 = d.create_task({"name": "Задача A", "planJoins": 25, "keywords": ["fl", "market", "python"]})
assert t1["planJoins"] == 25 and t1["status"] == "draft" and isinstance(t1["keywords"], list) assert t1["planJoins"] == 25 and t1["status"] == "draft" and isinstance(t1["keywords"], list)
try: try:
d.create_task({"name": "Задача B", "planJoins": 30}) d.create_task({"name": "Задача B", "planJoins": 30})
raise AssertionError("ожидался ValueError по бюджету") raise AssertionError("ожидался ValueError по бюджету")
except ValueError as exc: except ValueError as exc:
assert "исчерпан" in str(exc), exc assert "исчерпан" in str(exc), exc
assert len(d.list_tasks()) == 1 assert len(d.list_tasks()) == 1
# ── 2. add_candidate: источник уже в dialogs -> None + лог skip ───────────── # ── 2. add_candidate: источник уже в dialogs -> None + лог skip ─────────────
store.execute( store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, updated_at) VALUES (?, ?, '', 'чат', '#666', ?)", "INSERT INTO dialogs(id, name, handle, kind, hue, updated_at) VALUES (?, ?, '', 'чат', '#666', ?)",
["src_we_are_in", "Уже наш канал", 1], ["src_we_are_in", "Уже наш канал", 1],
) )
assert d.add_candidate(t1["id"], "src_we_are_in", "Уже наш канал", "our_ch", "channel", "#666") is None assert d.add_candidate(t1["id"], "src_we_are_in", "Уже наш канал", "our_ch", "channel", "#666") is None
skip_log = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"] skip_log = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"]
assert any("уже мониторится" in r["text"] for r in skip_log), d.task_log(t1["id"]) assert any("уже мониторится" in r["text"] for r in skip_log), d.task_log(t1["id"])
assert d.get_task(t1["id"])["found"] == 0 # skip не считается найденным assert d.get_task(t1["id"])["found"] == 0 # skip не считается найденным
# ── 3. mark_rejected -> чёрный список; повторный add_candidate -> None ────── # ── 3. mark_rejected -> чёрный список; повторный add_candidate -> None ──────
cand = d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00") cand = d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00")
assert cand is not None and cand["status"] == "new" assert cand is not None and cand["status"] == "new"
assert d.get_task(t1["id"])["found"] == 1 assert d.get_task(t1["id"])["found"] == 1
rej = d.mark_rejected("ch_bad", "спам") rej = d.mark_rejected("ch_bad", "спам")
assert rej["status"] == "rejected" assert rej["status"] == "rejected"
assert any(b["dialogId"] == "ch_bad" for b in d.list_blacklist()), d.list_blacklist() assert any(b["dialogId"] == "ch_bad" for b in d.list_blacklist()), d.list_blacklist()
assert d.get_task(t1["id"])["rejected"] == 1 assert d.get_task(t1["id"])["rejected"] == 1
assert d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00") is None assert d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00") is None
skip2 = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"] skip2 = [r for r in d.task_log(t1["id"]) if r["event"] == "skip"]
assert any("чёрном списке" in r["text"] for r in skip2), d.task_log(t1["id"]) assert any("чёрном списке" in r["text"] for r in skip2), d.task_log(t1["id"])
# ── 4. advance_search до конца ключей -> searchDone=True ──────────────────── # ── 4. advance_search до конца ключей -> searchDone=True ────────────────────
t = d.get_task(t1["id"]) t = d.get_task(t1["id"])
assert t["searchIdx"] == 0 and t["searchDone"] is False assert t["searchIdx"] == 0 and t["searchDone"] is False
for _ in range(3): for _ in range(3):
d.advance_search(t1["id"]) d.advance_search(t1["id"])
t = d.get_task(t1["id"]) t = d.get_task(t1["id"])
assert t["searchIdx"] == 3 and t["searchDone"] is True, t assert t["searchIdx"] == 3 and t["searchDone"] is True, t
# ── доп. проверки целостности интерфейсов ─────────────────────────────────── # ── доп. проверки целостности интерфейсов ───────────────────────────────────
assert d.patch_task(t1["id"], {"minSubscribers": 500, "autoJoin": True})["minSubscribers"] == 500 assert d.patch_task(t1["id"], {"minSubscribers": 500, "autoJoin": True})["minSubscribers"] == 500
assert d.list_candidates(t1["id"], status="rejected")[0]["dialogId"] == "ch_bad" assert d.list_candidates(t1["id"], status="rejected")[0]["dialogId"] == "ch_bad"
good = d.add_candidate(t1["id"], "ch_good", "Хор канал", "good_ch", "channel", "#0f0") good = d.add_candidate(t1["id"], "ch_good", "Хор канал", "good_ch", "channel", "#0f0")
assert good is not None assert good is not None
assert d.mark_joined(good["dialogId"], auto=False)["status"] == "joined" assert d.mark_joined(good["dialogId"], auto=False)["status"] == "joined"
assert d.get_task(t1["id"])["joined"] == 1 assert d.get_task(t1["id"])["joined"] == 1
assert [r["event"] for r in d.task_log(t1["id"])].count("join_manual") == 1 assert [r["event"] for r in d.task_log(t1["id"])].count("join_manual") == 1
print("DISCOVERY OK") print("DISCOVERY OK")
``` ```
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery.py``PY_COMPILE OK` (без ошибок). 1. `cd /c/telbase && python -m py_compile backend/app/services/discovery.py``PY_COMPILE OK` (без ошибок).
2. Сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_disc`, одноразовый контейнер `--rm --no-deps`) → `DISCOVERY OK`: 2. Сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_disc`, одноразовый контейнер `--rm --no-deps`) → `DISCOVERY OK`:
- задача `planJoins=25` создана; вторая с `planJoins=30``ValueError` («Бюджет авто-вступлений исчерпан…»); - задача `planJoins=25` создана; вторая с `planJoins=30``ValueError` («Бюджет авто-вступлений исчерпан…»);
- источник, вставленный в `dialogs`, → `add_candidate` вернул `None`, в `task_log` событие `skip` «уже мониторится», `found` не увеличен; - источник, вставленный в `dialogs`, → `add_candidate` вернул `None`, в `task_log` событие `skip` «уже мониторится», `found` не увеличен;
- `mark_rejected` → статус `rejected`, кандидат в `list_blacklist()`, счётчик `rejected=1`; повторный `add_candidate` того же источника → `None` + лог `skip` «в чёрном списке»; - `mark_rejected` → статус `rejected`, кандидат в `list_blacklist()`, счётчик `rejected=1`; повторный `add_candidate` того же источника → `None` + лог `skip` «в чёрном списке»;
- `advance_search` ×3 (3 ключа) → `searchIdx=3`, `searchDone=True`; - `advance_search` ×3 (3 ключа) → `searchIdx=3`, `searchDone=True`;
- доп.: `patch_task` (minSubscribers/autoJoin), `list_candidates(status=…)`, `mark_joined(auto=False)``joined=1` + лог `join_manual`. - доп.: `patch_task` (minSubscribers/autoJoin), `list_candidates(status=…)`, `mark_joined(auto=False)``joined=1` + лог `join_manual`.
3. Диагностика файла — без ошибок и предупреждений. 3. Диагностика файла — без ошибок и предупреждений.
## Concerns ## Concerns
1. **Конвенция ключей**: наружные dict-ы модуля — camelCase (не snake-колонки). Task 7 (роутер) может возвращать их как есть и передавать в `create/patch` `model_dump()` Pydantic-моделей; Task 6 (воркер) при работе с задачами/кандидатами должен использовать camelCase-ключи (`task["planJoins"]`, `c["fitRatio"]` и т.п.). Контракт зафиксирован в докстринге модуля. 1. **Конвенция ключей**: наружные dict-ы модуля — camelCase (не snake-колонки). Task 7 (роутер) может возвращать их как есть и передавать в `create/patch` `model_dump()` Pydantic-моделей; Task 6 (воркер) при работе с задачами/кандидатами должен использовать camelCase-ключи (`task["planJoins"]`, `c["fitRatio"]` и т.п.). Контракт зафиксирован в докстринге модуля.
2. `set_candidate_status` ограничен `new/review``joined/rejected` только через `mark_joined`/`mark_rejected` (иначе разъезжаются счётчики/чёрный список/лог). Если в Task 6/7 понадобится «сырой» перевод — расширить функцию осознанно. 2. `set_candidate_status` ограничен `new/review``joined/rejected` только через `mark_joined`/`mark_rejected` (иначе разъезжаются счётчики/чёрный список/лог). Если в Task 6/7 понадобится «сырой» перевод — расширить функцию осознанно.
3. `delete_task` дополнительно чистит `disc_log` задачи (в брифе — «задачу и её кандидатов»); чёрный список общий и не трогается. 3. `delete_task` дополнительно чистит `disc_log` задачи (в брифе — «задачу и её кандидатов»); чёрный список общий и не трогается.
4. `add_candidate` инкрементит `found` только при успешном добавлении (skip-источники не считаются найденными). 4. `add_candidate` инкрементит `found` только при успешном добавлении (skip-источники не считаются найденными).
5. Для прогонов в контейнере нужен `docker compose build app` (код запекается в образ) и `MSYS_NO_PATHCONV=1` на Git Bash (иначе аргументы `/data/…` и `/tmp/…` конвертируются в Windows-пути). 5. Для прогонов в контейнере нужен `docker compose build app` (код запекается в образ) и `MSYS_NO_PATHCONV=1` на Git Bash (иначе аргументы `/data/…` и `/tmp/…` конвертируются в Windows-пути).
@@ -1,19 +1,19 @@
### Task 4: Telegram-действия поиска (методы TelegramManager) ### Task 4: Telegram-действия поиска (методы TelegramManager)
**Files:** **Files:**
- Modify: `backend/app/services/telegram.py` (класс `TelegramManager`) - Modify: `backend/app/services/telegram.py` (класс `TelegramManager`)
**Interfaces:** **Interfaces:**
- Consumes: `self.client`, `ban_guard`. - Consumes: `self.client`, `ban_guard`.
- Produces (async-методы): - Produces (async-методы):
- `async def discovery_search(q: str, limit: int = 30) -> list[dict]``client(functions.contacts.SearchRequest(q=q, limit=limit))`; вернуть `[{id(str), name, username, kind, hue}]` (kind через `_kind_of`, hue через `dialog_hue`); между вызовами — `await asyncio.sleep(ban_guard.search_pause())`. - `async def discovery_search(q: str, limit: int = 30) -> list[dict]``client(functions.contacts.SearchRequest(q=q, limit=limit))`; вернуть `[{id(str), name, username, kind, hue}]` (kind через `_kind_of`, hue через `dialog_hue`); между вызовами — `await asyncio.sleep(ban_guard.search_pause())`.
- `async def discovery_info(dialog_id: str) -> dict``{id, name, username, kind, hue, participants: int | None, is_forum: bool}` (participants из `full_chat` где возможно; иначе None). - `async def discovery_info(dialog_id: str) -> dict``{id, name, username, kind, hue, participants: int | None, is_forum: bool}` (participants из `full_chat` где возможно; иначе None).
- `async def discovery_read(dialog_id: str, limit: int) -> dict` — последние сообщения: `{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}`; `topic_id``getattr(getattr(m,'reply_to',None),'reply_to_top_id',None)`. История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. - `async def discovery_read(dialog_id: str, limit: int) -> dict` — последние сообщения: `{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}`; `topic_id``getattr(getattr(m,'reply_to',None),'reply_to_top_id',None)`. История недоступна → `{"ok": False, "error": "no_history", "messages": []}`.
- `async def discovery_join(username: str) -> None``client(functions.channels.JoinChannelRequest(...))`; ПЕРЕД вызовом `await ban_guard.wait_join_delay()`; `FloodWaitError``ban_guard.note_flood()` и проброс. - `async def discovery_join(username: str) -> None``client(functions.channels.JoinChannelRequest(...))`; ПЕРЕД вызовом `await ban_guard.wait_join_delay()`; `FloodWaitError``ban_guard.note_flood()` и проброс.
- `async def discovery_leave(dialog_id: str) -> None``channels.LeaveChannelRequest`. - `async def discovery_leave(dialog_id: str) -> None``channels.LeaveChannelRequest`.
- `def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — INSERT/UPDATE `dialogs` с `monitor=TRUE, backfilled=FALSE` (как в `set_monitor`, но без авто-join-логики). - `def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — INSERT/UPDATE `dialogs` с `monitor=TRUE, backfilled=FALSE` (как в `set_monitor`, но без авто-join-логики).
- [ ] **Step 1: Реализовать методы** (импорт `telethon.tl.functions`, `telethon.errors.rpcerrorlist.FloodWaitError`). - [ ] **Step 1: Реализовать методы** (импорт `telethon.tl.functions`, `telethon.errors.rpcerrorlist.FloodWaitError`).
- [ ] **Step 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10). - [ ] **Step 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
--- ---
@@ -1,202 +1,202 @@
# Task 4 — Отчёт: Telegram-действия поиска (методы TelegramManager) # Task 4 — Отчёт: Telegram-действия поиска (методы TelegramManager)
Статус: **DONE** Статус: **DONE**
## Что сделано по шагам ## Что сделано по шагам
### Step 1: Методы в `TelegramManager` (`backend/app/services/telegram.py`) ### Step 1: Методы в `TelegramManager` (`backend/app/services/telegram.py`)
Добавлены в конец класса (секция `# ── discovery ...`), рядом с `dialog_messages`: Добавлены в конец класса (секция `# ── discovery ...`), рядом с `dialog_messages`:
- `discovery_search(q, limit=30) -> list[dict]``client(functions.contacts.SearchRequest(...))`; - `discovery_search(q, limit=30) -> list[dict]``client(functions.contacts.SearchRequest(...))`;
после запроса `await asyncio.sleep(ban_guard.search_pause())` (пауза «между поисками»). после запроса `await asyncio.sleep(ban_guard.search_pause())` (пауза «между поисками»).
Возвращает `[{id, name, username, kind, hue}]`: Возвращает `[{id, name, username, kind, hue}]`:
- `id`**подписанный** peer id (`utils.get_peer_id(entity)`), т.е. формат совпадает с - `id`**подписанный** peer id (`utils.get_peer_id(entity)`), т.е. формат совпадает с
`str(dlg.id)` в таблице `dialogs` (каналы `-100…`, базовые группы `-id`, люди `+id`). `str(dlg.id)` в таблице `dialogs` (каналы `-100…`, базовые группы `-id`, люди `+id`).
Это нужно для глобального правила «мы не состоим» (сравнение с `dialogs`). Это нужно для глобального правила «мы не состоим» (сравнение с `dialogs`).
- `name``utils.get_display_name(entity)` (как `Dialog.name`), `kind``_kind_of`, `hue``dialog_hue`. - `name``utils.get_display_name(entity)` (как `Dialog.name`), `kind``_kind_of`, `hue``dialog_hue`.
- Результаты дедуплицируются по `id`. - Результаты дедуплицируются по `id`.
- `discovery_info(dialog_id) -> dict``{id, name, username, kind, hue, participants, is_forum}`: - `discovery_info(dialog_id) -> dict``{id, name, username, kind, hue, participants, is_forum}`:
участники из `full_chat` (`GetFullChannelRequest` для каналов/супергрупп, участники из `full_chat` (`GetFullChannelRequest` для каналов/супергрупп,
`GetFullChatRequest` для базовых групп); при любой ошибке/недоступности — `participants=None`, `GetFullChatRequest` для базовых групп); при любой ошибке/недоступности — `participants=None`,
исключение наружу не бросается. исключение наружу не бросается.
- `discovery_read(dialog_id, limit) -> dict``{"ok", "error", "messages":[{id, text, date_ms, topic_id, topic_title}]}`; - `discovery_read(dialog_id, limit) -> dict``{"ok", "error", "messages":[{id, text, date_ms, topic_id, topic_title}]}`;
для форумов — выборка по активным темам (`channels.getForumTopics` + чтение каждой темы), для форумов — выборка по активным темам (`channels.getForumTopics` + чтение каждой темы),
плоский список с `topic_id`/`topic_title`; для обычных источников оба поля = `None`. плоский список с `topic_id`/`topic_title`; для обычных источников оба поля = `None`.
История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. Сообщения без История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. Сообщения без
текста (медиа/сервисные) пропускаются (как в `dialog_messages`/backfill). `limit <= 0` → пустой текста (медиа/сервисные) пропускаются (как в `dialog_messages`/backfill). `limit <= 0` → пустой
`ok` без сетевых вызовов. (правка тем форума — в «Fix round 1») `ok` без сетевых вызовов. (правка тем форума — в «Fix round 1»)
- `discovery_join(username) -> None``get_entity(username)` + `JoinChannelRequest`; - `discovery_join(username) -> None``get_entity(username)` + `JoinChannelRequest`;
`FloodWaitError``ban_guard.note_flood()` + `raise`. Пауза/квоты НЕ внутри — ручной join из API `FloodWaitError``ban_guard.note_flood()` + `raise`. Пауза/квоты НЕ внутри — ручной join из API
вне квот, `wait_join_delay()` перед авто-вступлением вызывает воркер (Task 6). вне квот, `wait_join_delay()` перед авто-вступлением вызывает воркер (Task 6).
Пустой username → `ValueError`. (правка — в «Fix round 1») Пустой username → `ValueError`. (правка — в «Fix round 1»)
- `discovery_leave(dialog_id) -> None``LeaveChannelRequest`. - `discovery_leave(dialog_id) -> None``LeaveChannelRequest`.
- `add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — синхронный upsert в `dialogs` - `add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — синхронный upsert в `dialogs`
(`monitor=TRUE, backfilled=FALSE`, `ON CONFLICT DO UPDATE` — по образцу `set_monitor`/`_persist_dialogs`) (`monitor=TRUE, backfilled=FALSE`, `ON CONFLICT DO UPDATE` — по образцу `set_monitor`/`_persist_dialogs`)
+ `_reload_monitored()`. Без авто-логики (никакого `_spawn(backfill)` — как в брифе). + `_reload_monitored()`. Без авто-логики (никакого `_spawn(backfill)` — как в брифе).
Импорты: `from telethon import ..., utils`, `FloodWaitError` (`telethon.errors.rpcerrorlist`), Импорты: `from telethon import ..., utils`, `FloodWaitError` (`telethon.errors.rpcerrorlist`),
`functions` (`telethon.tl`), `from . import ban_guard`. `progress.md` не трогал. `functions` (`telethon.tl`), `from . import ban_guard`. `progress.md` не трогал.
### Step 2: Проверка ### Step 2: Проверка
Выполнена (см. ниже). Живых Telegram-вызовов не делалось (E2E — Task 10). Выполнена (см. ниже). Живых Telegram-вызовов не делалось (E2E — Task 10).
## Изменённые файлы ## Изменённые файлы
- `backend/app/services/telegram.py` (импорты + 6 методов класса `TelegramManager`) - `backend/app/services/telegram.py` (импорты + 6 методов класса `TelegramManager`)
## Вывод проверок ## Вывод проверок
``` ```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py backend/app/services/ban_guard.py $ cd /c/telbase && python -m py_compile backend/app/services/telegram.py backend/app/services/ban_guard.py
PY_COMPILE_OK # без ошибок PY_COMPILE_OK # без ошибок
``` ```
Дополнительно (без сети, на временной БД `LEADRADAR_DATA=/tmp/lr_t4*`): Дополнительно (без сети, на временной БД `LEADRADAR_DATA=/tmp/lr_t4*`):
- импорт `from app.services import telegram, ban_guard``IMPORT_OK`, методы присутствуют; - импорт `from app.services import telegram, ban_guard``IMPORT_OK`, методы присутствуют;
- офлайн-сценарии: `discovery_search` без клиента → `RuntimeError`; `discovery_info` без клиента → - офлайн-сценарии: `discovery_search` без клиента → `RuntimeError`; `discovery_info` без клиента →
словарь с `participants=None, is_forum=False`; `discovery_read``no_history``ok=True` при `limit<=0`); словарь с `participants=None, is_forum=False`; `discovery_read``no_history``ok=True` при `limit<=0`);
`discovery_join('')``ValueError`; `discovery_leave` без клиента → `RuntimeError`; `discovery_join('')``ValueError`; `discovery_leave` без клиента → `RuntimeError`;
`add_dialog_monitored` → строка `monitor=TRUE, backfilled=FALSE` в `dialogs` + `_monitored` обновлён; `add_dialog_monitored` → строка `monitor=TRUE, backfilled=FALSE` в `dialogs` + `_monitored` обновлён;
`dialog_messages` (существующий метод) не сломан — `OFFLINE_OK`. `dialog_messages` (существующий метод) не сломан — `OFFLINE_OK`.
## Concerns ## Concerns
### Доступность полей Telethon (проверено интроспекцией установленного telethon==1.37.0) ### Доступность полей Telethon (проверено интроспекцией установленного telethon==1.37.0)
- **participants**: авторитетный источник — `ChannelFull.participants_count` из - **participants**: авторитетный источник — `ChannelFull.participants_count` из
`channels.GetFullChannelRequest` (каналы и супергруппы; работает для публичных каналов и без `channels.GetFullChannelRequest` (каналы и супергруппы; работает для публичных каналов и без
вступления). У самого `Channel` тоже есть `participants_count: Optional[int]`, но он не гарантирован, вступления). У самого `Channel` тоже есть `participants_count: Optional[int]`, но он не гарантирован,
поэтому в коде берём полный чат. Для базовой группы `ChatFull.participants` имеет тип поэтому в коде берём полный чат. Для базовой группы `ChatFull.participants` имеет тип
`ChatParticipants | ChatParticipantsForbidden` — считаем `len(participants.participants)`; `ChatParticipants | ChatParticipantsForbidden` — считаем `len(participants.participants)`;
`Forbidden`/ошибка → `None`. Для «чатов»-людей участников нет → `None`. Любая ошибка (приватный `Forbidden`/ошибка → `None`. Для «чатов»-людей участников нет → `None`. Любая ошибка (приватный
канал/группа без членства) → `None` по брифу. Такие кандидаты получат метку канал/группа без членства) → `None` по брифу. Такие кандидаты получат метку
«участники не подтверждены» (Task 6) вместо пропуска. «участники не подтверждены» (Task 6) вместо пропуска.
- **is_forum**: берётся с entity — `Channel.forum: Optional[bool]` (флаг конструктора `channel`). - **is_forum**: берётся с entity — `Channel.forum: Optional[bool]` (флаг конструктора `channel`).
⚠️ У `ChannelFull` поля `forum` НЕТ (есть только `view_forum_as_messages` — личная настройка ⚠️ У `ChannelFull` поля `forum` НЕТ (есть только `view_forum_as_messages` — личная настройка
просмотра, не признак форума). Fallback: если entity пришло в min-форме без флага — `False`. просмотра, не признак форума). Fallback: если entity пришло в min-форме без флага — `False`.
- **topic_id / темы форума**: `Message.reply_to``MessageReplyHeader.reply_to_top_id` (в 1.37 поле - **topic_id / темы форума**: `Message.reply_to``MessageReplyHeader.reply_to_top_id` (в 1.37 поле
есть), но для раскладки «по активным темам» (спека §6) плоской ленты недостаточно — чтение по есть), но для раскладки «по активным темам» (спека §6) плоской ленты недостаточно — чтение по
темам через `channels.GetForumTopicsRequest` реализовано в Fix round 1 (см. ниже). темам через `channels.GetForumTopicsRequest` реализовано в Fix round 1 (см. ниже).
### Прочее ### Прочее
1. **Кэш entity из поиска**: `discovery_search` делает `client.session.process_entities(found)` 1. **Кэш entity из поиска**: `discovery_search` делает `client.session.process_entities(found)`
иначе `discovery_info/read` по `dialog_id` не смогут резолвить кандидата до вступления (нет в иначе `discovery_info/read` по `dialog_id` не смогут резолвить кандидата до вступления (нет в
dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта. dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта.
2. **`discovery_join` и ручной join (Task 7)** — **закрыто в Fix round 1**: пауза убрана из 2. **`discovery_join` и ручной join (Task 7)** — **закрыто в Fix round 1**: пауза убрана из
`discovery_join` (ручной join — вне квот); `wait_join_delay()` перед авто-вступлением будет `discovery_join` (ручной join — вне квот); `wait_join_delay()` перед авто-вступлением будет
вызывать воркер (Task 6). вызывать воркер (Task 6).
3. **`add_dialog_monitored` не запускает backfill**: по брифу авто-логики нет (в отличие от 3. **`add_dialog_monitored` не запускает backfill**: по брифу авто-логики нет (в отличие от
`set_monitor`, где `_spawn(backfill_dialog)`). Строка остаётся `backfilled=FALSE`, и разбор `set_monitor`, где `_spawn(backfill_dialog)`). Строка остаётся `backfilled=FALSE`, и разбор
последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании; последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании;
если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызвать если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызвать
`tg.backfill_dialog(dialog_id)` явно (спека §7). `tg.backfill_dialog(dialog_id)` явно (спека §7).
4. **Ошибки поиска**: пауза стоит после успешного `contacts.search`; ошибки запроса (в т.ч. 4. **Ошибки поиска**: пауза стоит после успешного `contacts.search`; ошибки запроса (в т.ч.
`FloodWaitError`) пробрасываются без `note_flood` (бриф связывает flood-обработку только с `FloodWaitError`) пробрасываются без `note_flood` (бриф связывает flood-обработку только с
join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (события `flood`/`error`). join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (события `flood`/`error`).
5. **Юзеры в выдаче поиска**: `contacts.search` возвращает и людей (`_kind_of` → «чат»). По брифу 5. **Юзеры в выдаче поиска**: `contacts.search` возвращает и людей (`_kind_of` → «чат»). По брифу
не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало
сообщений) — при желании Task 6 может отфильтровать их раньше. сообщений) — при желании Task 6 может отфильтровать их раньше.
6. **Pyright-«шум»** в новых методах (`Entity | List[Entity]` в `JoinChannelRequest`, отсутствие 6. **Pyright-«шум»** в новых методах (`Entity | List[Entity]` в `JoinChannelRequest`, отсутствие
`process_entities` в стабах сессии и т.п.) — тот же класс предупреждений, что и в существующем `process_entities` в стабах сессии и т.п.) — тот же класс предупреждений, что и в существующем
коде (`backfill_dialog`, `dialog_messages`); на рантайм не влияет, код следует стилю файла. коде (`backfill_dialog`, `dialog_messages`); на рантайм не влияет, код следует стилю файла.
--- ---
## Fix round 1 ## Fix round 1
Правки по итогам ревью (только `backend/app/services/telegram.py`). Правки по итогам ревью (только `backend/app/services/telegram.py`).
### 1) Пауза убрана из `discovery_join` ### 1) Пауза убрана из `discovery_join`
- Удалён `await ban_guard.wait_join_delay()` из метода: ручной join из API (Task 7) — вне квот/пауз. - Удалён `await ban_guard.wait_join_delay()` из метода: ручной join из API (Task 7) — вне квот/пауз.
- Внутри осталась только обработка `FloodWaitError``ban_guard.note_flood()` + `raise`. - Внутри осталась только обработка `FloodWaitError``ban_guard.note_flood()` + `raise`.
- Паузу перед авто-вступлением теперь вызывает воркер (Task 6): `await ban_guard.wait_join_delay()` - Паузу перед авто-вступлением теперь вызывает воркер (Task 6): `await ban_guard.wait_join_delay()`
непосредственно перед `tg.discovery_join(...)`. `ban_guard` в файле по-прежнему используется непосредственно перед `tg.discovery_join(...)`. `ban_guard` в файле по-прежнему используется
(`search_pause` в `discovery_search`, `note_flood` в `discovery_join`). (`search_pause` в `discovery_search`, `note_flood` в `discovery_join`).
### 2) Форумные темы в `discovery_read` ### 2) Форумные темы в `discovery_read`
- Определение форума — `entity.forum`. - Определение форума — `entity.forum`.
- Если forum: `functions.channels.GetForumTopicsRequest(channel=entity, offset_date=0, - Если forum: `functions.channels.GetForumTopicsRequest(channel=entity, offset_date=0,
offset_id=0, offset_topic=0, limit=5)` → `topics`; для каждого topic читается до offset_id=0, offset_topic=0, limit=5)` → `topics`; для каждого topic читается до
`max(1, limit // len(topics))` последних сообщений через `client.get_messages(entity, limit=n, `max(1, limit // len(topics))` последних сообщений через `client.get_messages(entity, limit=n,
reply_to=topic.id)` (в 1.37 это `messages.GetRepliesRequest` — см. Concerns). reply_to=topic.id)` (в 1.37 это `messages.GetRepliesRequest` — см. Concerns).
- Возвращается плоский список `{id, text, date_ms, topic_id, topic_title}` (`topic_id=topic.id`, - Возвращается плоский список `{id, text, date_ms, topic_id, topic_title}` (`topic_id=topic.id`,
`topic_title=topic.title`); для non-forum оба поля `None` (topic_id из `reply_to_top_id` больше `topic_title=topic.title`); для non-forum оба поля `None` (topic_id из `reply_to_top_id` больше
не берётся — контракт брифа). не берётся — контракт брифа).
- Безопасность: исключения в темах не пробрасываются — тема, которая не прочиталась, - Безопасность: исключения в темах не пробрасываются — тема, которая не прочиталась,
пропускается; если не собрано ни одного сообщения тем — fallback на обычное чтение ленты пропускается; если не собрано ни одного сообщения тем — fallback на обычное чтение ленты
(General). Полный отказ и обычного чтения → `ok=False, error="no_history"`. Формат ответа (General). Полный отказ и обычного чтения → `ok=False, error="no_history"`. Формат ответа
сохранён: `{"ok", "error", "messages"}`. сохранён: `{"ok", "error", "messages"}`.
- Добавлены приватные хелперы: `_read_forum_topics(...)` (чтение тем) и - Добавлены приватные хелперы: `_read_forum_topics(...)` (чтение тем) и
`_discovery_message_item(...)` (общий фильтр непустого текста + сборка item для ленты и тем). `_discovery_message_item(...)` (общий фильтр непустого текста + сборка item для ленты и тем).
### Проверка Fix round 1 ### Проверка Fix round 1
``` ```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py $ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK PY_COMPILE_OK
``` ```
Офлайн-смоук без сети (`LEADRADAR_DATA=/tmp/lr_t4e`): `discovery_read` без клиента → `no_history`, Офлайн-смоук без сети (`LEADRADAR_DATA=/tmp/lr_t4e`): `discovery_read` без клиента → `no_history`,
`limit<=0` → пустой `ok`; `discovery_join('')` → `ValueError`; `_discovery_message_item` фильтрует `limit<=0` → пустой `ok`; `discovery_join('')` → `ValueError`; `_discovery_message_item` фильтрует
пустой текст и собирает поля `topic_id`/`topic_title` — `SMOKE_OK`. Живых Telegram-вызовов нет. пустой текст и собирает поля `topic_id`/`topic_title` — `SMOKE_OK`. Живых Telegram-вызовов нет.
### Concerns (Fix round 1) ### Concerns (Fix round 1)
1. Сигнатура `GetForumTopicsRequest` в 1.37: параметр называется `channel` (не `peer`), а 1. Сигнатура `GetForumTopicsRequest` в 1.37: параметр называется `channel` (не `peer`), а
параметра `offset` нет (есть `offset_date/offset_id/offset_topic`); вызываем с реальными именами. параметра `offset` нет (есть `offset_date/offset_id/offset_topic`); вызываем с реальными именами.
2. Поле темы в 1.37 — `ForumTopic.title` (не `top_title`); берём `topic.title`. 2. Поле темы в 1.37 — `ForumTopic.title` (не `top_title`); берём `topic.title`.
3. `messages.GetHistoryRequest` в 1.37 не имеет `top_msg_id`; `client.get_messages(..., 3. `messages.GetHistoryRequest` в 1.37 не имеет `top_msg_id`; `client.get_messages(...,
reply_to=topic_id)` реализован через `messages.GetRepliesRequest(peer, msg_id=topic_id)` — в reply_to=topic_id)` реализован через `messages.GetRepliesRequest(peer, msg_id=topic_id)` — в
Telegram сообщения темы форума являются «ответами» на её стартовое сообщение, поэтому это Telegram сообщения темы форума являются «ответами» на её стартовое сообщение, поэтому это
корректный способ чтения темы. Ручной fallback через `GetHistoryRequest(top_msg_id=…)` в 1.37 корректный способ чтения темы. Ручной fallback через `GetHistoryRequest(top_msg_id=…)` в 1.37
невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента. невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента.
4. Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на 4. Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на
живом аккаунте в E2E (Task 10). живом аккаунте в E2E (Task 10).
--- ---
## Fix round 2 ## Fix round 2
Правка по замечанию ревью (только `backend/app/services/telegram.py`). Правка по замечанию ревью (только `backend/app/services/telegram.py`).
### Изменение ### Изменение
- В `_read_forum_topics` размер выборки на тему изменён с `max(1, limit // len(topics))` - В `_read_forum_topics` размер выборки на тему изменён с `max(1, limit // len(topics))`
на `min(max(3, math.ceil(limit / len(topics))), 10)`: на `min(max(3, math.ceil(limit / len(topics))), 10)`:
- минимум **3** сообщения на тему — иначе типичная тема не набирает порог - минимум **3** сообщения на тему — иначе типичная тема не набирает порог
«мало сообщений» (Task 5: `passed` требует ≥3 содержательных) и многотемные «мало сообщений» (Task 5: `passed` требует ≥3 содержательных) и многотемные
форумы почти всегда отсеивались бы как «мало подходящих»; форумы почти всегда отсеивались бы как «мало подходящих»;
- `ceil` вместо целочисленного деления — при `limit=10` и 5 темах теперь 3, а не 2; - `ceil` вместо целочисленного деления — при `limit=10` и 5 темах теперь 3, а не 2;
- cap **10** — не выкачиваем больше десятка на тему (sample_size ограничен 3..30, - cap **10** — не выкачиваем больше десятка на тему (sample_size ограничен 3..30,
чтение по темам и так дороже плоской ленты). чтение по темам и так дороже плоской ленты).
- Добавлен `import math`. Поведение без тем/с ошибками не менялось: пустой список тем и - Добавлен `import math`. Поведение без тем/с ошибками не менялось: пустой список тем и
исключения по-прежнему ведут к fallback на обычное чтение ленты (General); суммарная исключения по-прежнему ведут к fallback на обычное чтение ленты (General); суммарная
выборка может слегка превышать `limit` — осознанно для форумов. выборка может слегка превышать `limit` — осознанно для форумов.
Примеры расчёта: limit=10/5 тем → 3 на тему; limit=10/1 тема → 10; limit=30/5 тем → 6; Примеры расчёта: limit=10/5 тем → 3 на тему; limit=10/1 тема → 10; limit=30/5 тем → 6;
limit=30/10 тем → 3. limit=30/10 тем → 3.
### Проверка ### Проверка
``` ```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py $ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK # без ошибок PY_COMPILE_OK # без ошибок
``` ```
### Re-review (scoped, по текущему коду `_read_forum_topics`) ### Re-review (scoped, по текущему коду `_read_forum_topics`)
Вердикт: **ADDRESSED** — новых Critical/Important в фиксе нет. Вердикт: **ADDRESSED** — новых Critical/Important в фиксе нет.
1. **Формула и cap корректны** (L784 `min(max(3, math.ceil(limit / len(topics))), 10)`): 1. **Формула и cap корректны** (L784 `min(max(3, math.ceil(limit / len(topics))), 10)`):
- limit=10 / 5 тем: `ceil(10/5)=2` → `max(3,2)=3` → `min(3,10)=3` ✓ - limit=10 / 5 тем: `ceil(10/5)=2` → `max(3,2)=3` → `min(3,10)=3` ✓
- limit=30 / 10 тем: `ceil(3)=3` → 3 ✓ - limit=30 / 10 тем: `ceil(3)=3` → 3 ✓
- limit=30 / 5 тем: `ceil(6)=6` → 6 ✓ - limit=30 / 5 тем: `ceil(6)=6` → 6 ✓
Нижняя граница (3) и cap (10) на месте; `ceil` даёт int, деления на ноль нет — Нижняя граница (3) и cap (10) на месте; `ceil` даёт int, деления на ноль нет —
`if not topics: return out` (L780–781) стоит до расчёта. `if not topics: return out` (L780–781) стоит до расчёта.
2. **Fallback и обработка ошибок не сломаны**: пустой список тем и исключение 2. **Fallback и обработка ошибок не сломаны**: пустой список тем и исключение
`getForumTopicsRequest` → `return out` → в `discovery_read` пустой результат ведёт к `getForumTopicsRequest` → `return out` → в `discovery_read` пустой результат ведёт к
обычной ленте (General) (L746747); ошибка чтения отдельной темы → `continue` обычной ленте (General) (L746747); ошибка чтения отдельной темы → `continue`
(L793795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743745). (L793795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743745).
Строки fallback-путей не менялись. Строки fallback-путей не менялись.
3. **Новых проблем в фрагменте нет**: `import math` не конфликтует (имя `math` в модуле 3. **Новых проблем в фрагменте нет**: `import math` не конфликтует (имя `math` в модуле
ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783) ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783)
соответствует поведению. соответствует поведению.
Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное): Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное):
- `GetForumTopicsRequest` имеет `limit=5`, поэтому фактически `len(topics) ≤ 5` — случай - `GetForumTopicsRequest` имеет `limit=5`, поэтому фактически `len(topics) ≤ 5` — случай
«30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи «30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи
тем вырастет. тем вырастет.
- Пол «минимум 3» может заметно превышать маленький `limit` (напр. limit=3 при 5 темах → - Пол «минимум 3» может заметно превышать маленький `limit` (напр. limit=3 при 5 темах →
до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочих до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочих
`sample_size` 3..30 деградации нет. `sample_size` 3..30 деградации нет.
@@ -1,24 +1,24 @@
### Task 5: Оценка контента (язык, темы, fit по профилю задачи) ### Task 5: Оценка контента (язык, темы, fit по профилю задачи)
**Files:** **Files:**
- Create: `backend/app/services/discovery_eval.py` - Create: `backend/app/services/discovery_eval.py`
**Interfaces:** **Interfaces:**
- Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`. - Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`.
- Produces: - Produces:
- `def detect_lang_ru(texts: list[str]) -> bool | None` — доля кириллических букв от всех букв в сумме: `>=0.15 → True`; `<=0.03 → False`; между порогами → `None` (неопределённо). - `def detect_lang_ru(texts: list[str]) -> bool | None` — доля кириллических букв от всех букв в сумме: `>=0.15 → True`; `<=0.03 → False`; между порогами → `None` (неопределённо).
- `def group_by_topic(messages: list[dict]) -> list[dict]` — группировка по `topic_id` (None → "main"); возвращает `[{"topic_id", "title", "messages": [...]}]`, title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.). - `def group_by_topic(messages: list[dict]) -> list[dict]` — группировка по `topic_id` (None → "main"); возвращает `[{"topic_id", "title", "messages": [...]}]`, title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).
- `async def evaluate_message(task: dict, text: str) -> dict``{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`: - `async def evaluate_message(task: dict, text: str) -> dict``{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`:
1) текст пустой/длина <10 → fit False «слишком короткое»; 1) текст пустой/длина <10 → fit False «слишком короткое»;
2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»; 2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»;
3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4; 3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4;
4) эвристика: fit = любой ключ входит в `clean_short(text)` casefold; reason «совпал ключ "…"» / «нет совпадений с ключами». 4) эвристика: fit = любой ключ входит в `clean_short(text)` casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
- `async def evaluate_sample(task: dict, messages: list[dict]) -> dict` — последовательно по каждому сообщению; вернуть `{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}`. - `async def evaluate_sample(task: dict, messages: list[dict]) -> dict` — последовательно по каждому сообщению; вернуть `{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}`.
- `def passed(ev: dict, task: dict) -> bool``ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`. - `def passed(ev: dict, task: dict) -> bool``ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`.
- [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа): - [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа):
`Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.` `Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.`
- [ ] **Step 2: Проверить на временной БД** (без сети): `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; `group_by_topic` объединяет по topic_id и сортирует; `evaluate_message` на задаче без ИИ/ML возвращает эвристический fit по ключу. - [ ] **Step 2: Проверить на временной БД** (без сети): `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; `group_by_topic` объединяет по topic_id и сортирует; `evaluate_message` на задаче без ИИ/ML возвращает эвристический fit по ключу.
--- ---
@@ -1,42 +1,42 @@
# Task 5 — Отчёт: Оценка контента (язык, темы, fit по профилю задачи) # Task 5 — Отчёт: Оценка контента (язык, темы, fit по профилю задачи)
## Статус ## Статус
✅ Реализовано и проверено (py_compile + офлайн-сценарий на временной БД в контейнере). ✅ Реализовано и проверено (py_compile + офлайн-сценарий на временной БД в контейнере).
## Файлы ## Файлы
- Создан: `backend/app/services/discovery_eval.py` — чистая логика оценки (без карточек/очередей/обучения), поверх `store`, `ml_client`, `ai_service.chat_json`, `pipeline.clean_short`. - Создан: `backend/app/services/discovery_eval.py` — чистая логика оценки (без карточек/очередей/обучения), поверх `store`, `ml_client`, `ai_service.chat_json`, `pipeline.clean_short`.
## Что сделано ## Что сделано
### Интерфейсы брифа ### Интерфейсы брифа
- `detect_lang_ru(texts)` — суммарная доля кириллицы (блок U+0400–U+04FF) среди всех `str.isalpha()`-букв выборки: `>=0.15 → True`, `<=0.03 → False`, между порогами или 0 букв → `None`. - `detect_lang_ru(texts)` — суммарная доля кириллицы (блок U+0400–U+04FF) среди всех `str.isalpha()`-букв выборки: `>=0.15 → True`, `<=0.03 → False`, между порогами или 0 букв → `None`.
- `group_by_topic(messages)` — группировка по `topic_id` (`None → "main"`); `[{"topic_id", "title", "messages": [...]}]`; title — сниппет первого непустого текста темы (≤60 симв., whitespace схлопнут); группы отсортированы по числу сообщений (убыв.), порядок сообщений внутри группы — входной (хронологический из `discovery_read`). - `group_by_topic(messages)` — группировка по `topic_id` (`None → "main"`); `[{"topic_id", "title", "messages": [...]}]`; title — сниппет первого непустого текста темы (≤60 симв., whitespace схлопнут); группы отсортированы по числу сообщений (убыв.), порядок сообщений внутри группы — входной (хронологический из `discovery_read`).
- `evaluate_message(task, text)` async — каскад: - `evaluate_message(task, text)` async — каскад:
1. текст пустой/`len(strip) < 10``{"fit": False, "reason": "слишком короткое", "source": "heuristic"}`; 1. текст пустой/`len(strip) < 10``{"fit": False, "reason": "слишком короткое", "source": "heuristic"}`;
2. ML: `ml_client.is_enabled()``predict(text)`; `take` и `label=='spam'``False`, reason «ML: спам», `source: "ml"`; иначе ниже; 2. ML: `ml_client.is_enabled()``predict(text)`; `take` и `label=='spam'``False`, reason «ML: спам», `source: "ml"`; иначе ниже;
3. ИИ: если `aiEnabled` **и** доступен ключ/локальный провайдер (см. Concerns) — один `ai_service.chat_json(промпт, user="Сообщение:\n"+text[:4000])`; `{fit, reason}` из ответа; любая ошибка → шаг 4; 3. ИИ: если `aiEnabled` **и** доступен ключ/локальный провайдер (см. Concerns) — один `ai_service.chat_json(промпт, user="Сообщение:\n"+text[:4000])`; `{fit, reason}` из ответа; любая ошибка → шаг 4;
4. эвристика: любой ключ входит в `clean_short(text).casefold()`; reason `совпал ключ "…"` / `нет совпадений с ключами`, `source: "heuristic"`. 4. эвристика: любой ключ входит в `clean_short(text).casefold()`; reason `совпал ключ "…"` / `нет совпадений с ключами`, `source: "heuristic"`.
- `evaluate_sample(task, messages)` async — последовательно по сообщениям; `{"fit_count", "total", "fit_ratio", "per_message": [{"text", "fit", "reason", "topic_id"}]}`; `topic_id` в per_message нормализован `None → "main"` (стыкуется с ключами `group_by_topic`). - `evaluate_sample(task, messages)` async — последовательно по сообщениям; `{"fit_count", "total", "fit_ratio", "per_message": [{"text", "fit", "reason", "topic_id"}]}`; `topic_id` в per_message нормализован `None → "main"` (стыкуется с ключами `group_by_topic`).
- `passed(ev, task)``ev["total"] >= 3` и `ev["fit_ratio"]*100 >= task["threshold"]`. - `passed(ev, task)``ev["total"] >= 3` и `ev["fit_ratio"]*100 >= task["threshold"]`.
- Промпт ИИ — константа `_AI_PROMPT` точно по тексту брифа (description/keywords подставляются через `.format`). - Промпт ИИ — константа `_AI_PROMPT` точно по тексту брифа (description/keywords подставляются через `.format`).
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_eval.py``PY_COMPILE OK` (без ошибок). 1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_eval.py``PY_COMPILE OK` (без ошибок).
2. Сборка и офлайн-прогон на временной БД: 2. Сборка и офлайн-прогон на временной БД:
- `docker compose build app` → образ собран; - `docker compose build app` → образ собран;
- `MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_eval --entrypoint python app /data/task5_check.py``DISCOVERY_EVAL OK`: - `MSYS_NO_PATHCONV=1 docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_eval --entrypoint python app /data/task5_check.py``DISCOVERY_EVAL OK`:
- `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; смесь 1/20 кириллицы → `None`; текст без букв → `None`; - `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; смесь 1/20 кириллицы → `None`; текст без букв → `None`;
- `group_by_topic`: темы `main`/111/222, объединение 2 сообщений в 111, сортировка `[111, main, 222]`, title «Топик A первый» (≤60), входной порядок внутри группы сохранён; - `group_by_topic`: темы `main`/111/222, объединение 2 сообщений в 111, сортировка `[111, main, 222]`, title «Топик A первый» (≤60), входной порядок внутри группы сохранён;
- `evaluate_message` на задаче без ИИ/ML (`aiEnabled=False`, `mlEnabled=False`) → эвристика: `{"fit": True, "reason": "совпал ключ "python"", "source": "heuristic"}`; без ключа → False «нет совпадений с ключами»; «короче» → False «слишком короткое»; - `evaluate_message` на задаче без ИИ/ML (`aiEnabled=False`, `mlEnabled=False`) → эвристика: `{"fit": True, "reason": "совпал ключ "python"", "source": "heuristic"}`; без ключа → False «нет совпадений с ключами»; «короче» → False «слишком короткое»;
- `aiEnabled=True` без ключа провайдера → ИИ-ветка не вызывается (без сети), отвечает эвристика; - `aiEnabled=True` без ключа провайдера → ИИ-ветка не вызывается (без сети), отвечает эвристика;
- `evaluate_sample`: total=4/fit=2/ratio=0.5, per_message topic_id `["main","main",5,5]`; `passed`: threshold 40 → True, 60 → False, выборка из 2 → False, пустая → ratio 0.0/False. - `evaluate_sample`: total=4/fit=2/ratio=0.5, per_message topic_id `["main","main",5,5]`; `passed`: threshold 40 → True, 60 → False, выборка из 2 → False, пустая → ratio 0.0/False.
- Временный файл `data/task5_check.py` удалён после прогона. - Временный файл `data/task5_check.py` удалён после прогона.
3. Диагностика файла — без ошибок и предупреждений. 3. Диагностика файла — без ошибок и предупреждений.
## Concerns ## Concerns
1. **`source` для слишком коротких сообщений** — `"heuristic"`: это первая ступень каскада (до ML/ИИ), но `source` по брифу — объединение `heuristic|ml|ai`, отдельного значения нет. Воркеру (Task 6) это не мешает; при необходимости подсчёта «отсевов по длине» лучше ориентироваться на `reason`. 1. **`source` для слишком коротких сообщений** — `"heuristic"`: это первая ступень каскада (до ML/ИИ), но `source` по брифу — объединение `heuristic|ml|ai`, отдельного значения нет. Воркеру (Task 6) это не мешает; при необходимости подсчёта «отсевов по длине» лучше ориентироваться на `reason`.
2. **ИИ-ветка дополнительно проверяет наличие ключа** (`ai_service.provider_status()`: `keySet` или `local`-провайдер), а не только `aiEnabled` — иначе `chat_json` при отсутствии ключа тратит ~6 c на ретраи и сыплет warning в лог на каждое сообщение. При любой ошибке/недоступности статуса — всё равно fallback на эвристику (как и требует бриф). 2. **ИИ-ветка дополнительно проверяет наличие ключа** (`ai_service.provider_status()`: `keySet` или `local`-провайдер), а не только `aiEnabled` — иначе `chat_json` при отсутствии ключа тратит ~6 c на ретраи и сыплет warning в лог на каждое сообщение. При любой ошибке/недоступности статуса — всё равно fallback на эвристику (как и требует бриф).
3. **`topic_id` в `per_message` нормализован** `None → "main"`, чтобы результат `evaluate_sample` стыковался с ключами `group_by_topic`. Task 6 при подсчёте «подходит X из N» по темам форума должен сравнивать ключ `"main"`, а не `None`. 3. **`topic_id` в `per_message` нормализован** `None → "main"`, чтобы результат `evaluate_sample` стыковался с ключами `group_by_topic`. Task 6 при подсчёте «подходит X из N» по темам форума должен сравнивать ключ `"main"`, а не `None`.
4. **`title` темы** — сниппет первого **непустого** текста темы (первого во входном порядке), а не обязательно первого сообщения; `topic_title` из сообщений не используется (в `discovery_read` Task 4 его и нет). Если позже понадобится название темы из Telegram — добавить как fallback для пустых текстов. 4. **`title` темы** — сниппет первого **непустого** текста темы (первого во входном порядке), а не обязательно первого сообщения; `topic_title` из сообщений не используется (в `discovery_read` Task 4 его и нет). Если позже понадобится название темы из Telegram — добавить как fallback для пустых текстов.
5. **Лимиты на границе**: тексту в ИИ обрезается до 4000 симв. (как в `ai.filter_incoming`), причина из ИИ — до 200 симв.; эвристика и длина считаются по полному тексту. 5. **Лимиты на границе**: тексту в ИИ обрезается до 4000 симв. (как в `ai.filter_incoming`), причина из ИИ — до 200 симв.; эвристика и длина считаются по полному тексту.
6. Решения оценки нигде не логируются и не учитываются в счётчиках `ml_client.track_decisions` — модуль чистый; учёт/логирование при необходимости добавить в Task 6 на уровне воркера. 6. Решения оценки нигде не логируются и не учитываются в счётчиках `ml_client.track_decisions` — модуль чистый; учёт/логирование при необходимости добавить в Task 6 на уровне воркера.
@@ -1,28 +1,28 @@
### Task 6: Воркер Discovery (поиск → оценка → авто-вступление) ### Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
**Files:** **Files:**
- Create: `backend/app/services/discovery_worker.py` - Create: `backend/app/services/discovery_worker.py`
- Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c) - Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c)
**Interfaces:** **Interfaces:**
- Consumes: `discovery` (Task 3), `tg.discovery_*` (Task 4), `discovery_eval` (Task 5), `ban_guard` (Task 2). - Consumes: `discovery` (Task 3), `tg.discovery_*` (Task 4), `discovery_eval` (Task 5), `ban_guard` (Task 2).
- Produces: `async def tick() -> dict` — выполняет ОДНО действие и возвращает `{"action": ..., "taskId": ...}` (или `{"action": "none"}`). - Produces: `async def tick() -> dict` — выполняет ОДНО действие и возвращает `{"action": ..., "taskId": ...}` (или `{"action": "none"}`).
Логика tick (по одной задаче за вызов, начиная с самой старой running): Логика tick (по одной задаче за вызов, начиная с самой старой running):
1. Если задача `search_done=False`: взять ключ `keywords[search_idx]`, вызвать `tg.discovery_search`; для каждого результата `discovery.add_candidate`; `discovery.advance_search(task_id)`; если `search_done` стал True — лог `search` «поиск завершён: N кандидатов». Возврат. 1. Если задача `search_done=False`: взять ключ `keywords[search_idx]`, вызвать `tg.discovery_search`; для каждого результата `discovery.add_candidate`; `discovery.advance_search(task_id)`; если `search_done` стал True — лог `search` «поиск завершён: N кандидатов». Возврат.
2. Иначе взять первого кандидата статуса `new` задачи: 2. Иначе взять первого кандидата статуса `new` задачи:
- `info = tg.discovery_info`; `participants`, `kind` (forum если `is_forum`); при заданном `min_subscribers` и participants НЕ None и меньше минимума — `set_candidate_status(...)` нет: просто `discovery.delete_candidate` + лог `skip`; если participants None — метка «участники не подтверждены» (идём дальше). - `info = tg.discovery_info`; `participants`, `kind` (forum если `is_forum`); при заданном `min_subscribers` и participants НЕ None и меньше минимума — `set_candidate_status(...)` нет: просто `discovery.delete_candidate` + лог `skip`; если participants None — метка «участники не подтверждены» (идём дальше).
- `read = tg.discovery_read(dialog_id, sample_size)`. - `read = tg.discovery_read(dialog_id, sample_size)`.
- Если `read.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются. - Если `read.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
- Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён». - Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён».
- Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)». - Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)».
3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`: 3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`:
- повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом; - повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом;
- `await ban_guard.wait_join_delay()` (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают); - `await ban_guard.wait_join_delay()` (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);
- `tg.discovery_join(username)``discovery.mark_joined(dialog_id, auto=True)``tg.add_dialog_monitored(...)`; при FloodWaitError → `ban_guard.note_flood()` + лог `flood`. - `tg.discovery_join(username)``discovery.mark_joined(dialog_id, auto=True)``tg.add_dialog_monitored(...)`; при FloodWaitError → `ban_guard.note_flood()` + лог `flood`.
4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`. 4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`.
- [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`. - [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`.
- [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную. - [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную.
--- ---
@@ -1,60 +1,60 @@
# Task 6 — Отчёт: воркер Discovery (поиск → оценка → авто-вступление) # Task 6 — Отчёт: воркер Discovery (поиск → оценка → авто-вступление)
## Статус ## Статус
✅ Реализовано и проверено (py_compile + сборка образа + офлайн-прогон на временной БД с моками Telegram/пауз). ✅ Реализовано и проверено (py_compile + сборка образа + офлайн-прогон на временной БД с моками Telegram/пауз).
## Файлы ## Файлы
- Создан: `backend/app/services/discovery_worker.py``async def tick() -> dict` + шаги. - Создан: `backend/app/services/discovery_worker.py``async def tick() -> dict` + шаги.
- Изменён: `backend/app/main.py` — фоновый цикл `_discovery_loop` (каждые 5 c: `await tick()`, исключения — `log.exception`) и запуск в lifespan в списке задач рядом с `_pipeline_loop`. - Изменён: `backend/app/main.py` — фоновый цикл `_discovery_loop` (каждые 5 c: `await tick()`, исключения — `log.exception`) и запуск в lifespan в списке задач рядом с `_pipeline_loop`.
## Что сделано ## Что сделано
### Структура `tick()` (одно действие за вызов, возврат `{"action": ..., "taskId": ...}`) ### Структура `tick()` (одно действие за вызов, возврат `{"action": ..., "taskId": ...}`)
Приоритеты (как в брифе + пожелание про стоп-кран): Приоритеты (как в брифе + пожелание про стоп-кран):
0. `ban_guard.global_paused()` → сразу `{"action": "none"}` (стоп-кран останавливает весь tick). 0. `ban_guard.global_paused()` → сразу `{"action": "none"}` (стоп-кран останавливает весь tick).
1. **План выполнен** (`joined >= planJoins` у любой running-задачи) → `status='done'` + лог `done` («план выполнен: вступили X из Y»). Идёт ДО поиска/оценки/join, чтобы задачу с выполненным планом не продолжать обрабатывать (и чтобы освободился бюджет планов). 1. **План выполнен** (`joined >= planJoins` у любой running-задачи) → `status='done'` + лог `done` («план выполнен: вступили X из Y»). Идёт ДО поиска/оценки/join, чтобы задачу с выполненным планом не продолжать обрабатывать (и чтобы освободился бюджет планов).
2. **Шаг поиска**: первая running-задача с `searchDone=False` → ключ `keywords[searchIdx]``tg.discovery_search` → каждый результат `discovery.add_candidate` (kind нормализуется: `канал/группа/чат → channel/group/forum`) → `advance_search` → при переходе в `searchDone` лог `search` «поиск завершён: N кандидатов» (N — счётчик `found`). Паузы между поисками — внутри `discovery_search`. Пустые/съехавшие ключи закрываются без сетевого вызова. 2. **Шаг поиска**: первая running-задача с `searchDone=False` → ключ `keywords[searchIdx]``tg.discovery_search` → каждый результат `discovery.add_candidate` (kind нормализуется: `канал/группа/чат → channel/group/forum`) → `advance_search` → при переходе в `searchDone` лог `search` «поиск завершён: N кандидатов» (N — счётчик `found`). Паузы между поисками — внутри `discovery_search`. Пустые/съехавшие ключи закрываются без сетевого вызова.
3. **Шаг оценки**: первый кандидат `status='new'`: 3. **Шаг оценки**: первый кандидат `status='new'`:
- `discovery_info``participants`, `kind` (`forum`, если `is_forum`; иначе маппинг RU-kind), обновляются name/username/hue; - `discovery_info``participants`, `kind` (`forum`, если `is_forum`; иначе маппинг RU-kind), обновляются name/username/hue;
- `minSubscribers`>0: participants меньше → `delete_candidate` + лог `skip` «мало участников (X < min)»; participants не получены → метка «участники не подтверждены»; - `minSubscribers`>0: participants меньше → `delete_candidate` + лог `skip` «мало участников (X < min)»; participants не получены → метка «участники не подтверждены»;
- `discovery_read`: `ok=False``review` + метка «канал: история недоступна» (channel) или «закрытая группа (история скрыта) — вступите сами» (group/forum); контент не оценивается, для ru-задач добавляется метка «язык не подтверждён»; - `discovery_read`: `ok=False``review` + метка «канал: история недоступна» (channel) или «закрытая группа (история скрыта) — вступите сами» (group/forum); контент не оценивается, для ru-задач добавляется метка «язык не подтверждён»;
- язык (только ru-задачи): `False``delete_candidate` + skip «язык не русский»; `None` → метка «язык не подтверждён»; - язык (только ru-задачи): `False``delete_candidate` + skip «язык не русский»; `None` → метка «язык не подтверждён»;
- объём: содержательных <3 → `review` + метка «мало сообщений» (решает человек); - объём: содержательных <3 → `review` + метка «мало сообщений» (решает человек);
- контент: не-форум — `evaluate_sample` по всей выборке, `fitRatio` общий; форум — `group_by_topic`, `evaluate_sample` по каждой теме, заполняется `topics` (`{topicId, title, fitCount, total, fitRatio, passed}`), вердикт — есть ≥1 проходная тема, `fitRatio` — агрегат fit из N по всей выборке; `passed()``review` (метки/fitRatio/topics), иначе `delete_candidate` + skip «мало подходящих (X из N)»; - контент: не-форум — `evaluate_sample` по всей выборке, `fitRatio` общий; форум — `group_by_topic`, `evaluate_sample` по каждой теме, заполняется `topics` (`{topicId, title, fitCount, total, fitRatio, passed}`), вердикт — есть ≥1 проходная тема, `fitRatio` — агрегат fit из N по всей выборке; `passed()``review` (метки/fitRatio/topics), иначе `delete_candidate` + skip «мало подходящих (X из N)»;
- `bump_counter(evaluated)` при выходе кандидата из `new` (review или delete). - `bump_counter(evaluated)` при выходе кандидата из `new` (review или delete).
4. **Авто-вступление** (отдельный проход, приоритет ниже оценки): задача `autoJoin=True` + кандидат `review` + `ban_guard.can_auto_join()` (иначе `none`): 4. **Авто-вступление** (отдельный проход, приоритет ниже оценки): задача `autoJoin=True` + кандидат `review` + `ban_guard.can_auto_join()` (иначе `none`):
- повторная проверка «мы не состоим» прямым SQL по `dialogs`/`disc_blacklist` (интерфейсы Task 3 не менялись) — если уже вступили/в чёрном списке → `mark_rejected` + лог `reject`, action `reject`; - повторная проверка «мы не состоим» прямым SQL по `dialogs`/`disc_blacklist` (интерфейсы Task 3 не менялись) — если уже вступили/в чёрном списке → `mark_rejected` + лог `reject`, action `reject`;
- `await ban_guard.wait_join_delay()` (5070 с); - `await ban_guard.wait_join_delay()` (5070 с);
- `tg.discovery_join(username)``mark_joined(auto=True)``tg.add_dialog_monitored(dialogId, name, username, kind, hue)`; - `tg.discovery_join(username)``mark_joined(auto=True)``tg.add_dialog_monitored(dialogId, name, username, kind, hue)`;
- `FloodWaitError``ban_guard.note_flood()` (идемпотентно) + лог `flood`; прочие ошибки → лог `error` (кандидат остаётся `review` для ретрая). - `FloodWaitError``ban_guard.note_flood()` (идемпотентно) + лог `flood`; прочие ошибки → лог `error` (кандидат остаётся `review` для ретрая).
Возвращаемые action: `search|review|skip|join|reject|flood|error|done|none`. Метки/topics-контракт продублирован в docstring модуля. Возвращаемые action: `search|review|skip|join|reject|flood|error|done|none`. Метки/topics-контракт продублирован в docstring модуля.
### Контракт меток и тем (в docstring `discovery_worker.py`) ### Контракт меток и тем (в docstring `discovery_worker.py`)
- `marks` — список строк: «участники не подтверждены», «язык не подтверждён», «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», «мало сообщений». - `marks` — список строк: «участники не подтверждены», «язык не подтверждён», «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», «мало сообщений».
- `topics` — список dict для форумов: `{"topicId": str|int, "title": str, "fitCount": int, "total": int, "fitRatio": float, "passed": bool}`. Общий вердикт форума — есть хотя бы одна проходная тема; `fitRatio` кандидата — агрегат по всей выборке; для не-форумов `topics` не заполняется. - `topics` — список dict для форумов: `{"topicId": str|int, "title": str, "fitCount": int, "total": int, "fitRatio": float, "passed": bool}`. Общий вердикт форума — есть хотя бы одна проходная тема; `fitRatio` кандидата — агрегат по всей выборке; для не-форумов `topics` не заполняется.
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py backend/app/main.py``PY_COMPILE_OK` (без ошибок). 1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_worker.py backend/app/main.py``PY_COMPILE_OK` (без ошибок).
2. `docker compose build app``Image telbase-app Built` (3 c, кэш). 2. `docker compose build app``Image telbase-app Built` (3 c, кэш).
3. Офлайн-сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_w6`, `MSYS_NO_PATHCONV=1`, `--entrypoint sh`), Telegram-методы и `wait_join_delay` замоканы, оценка — реальная (эвристика: `aiEnabled/mlEnabled=False`): 3. Офлайн-сценарий в контейнере на временной БД (`LEADRADAR_DATA=/tmp/lr_w6`, `MSYS_NO_PATHCONV=1`, `--entrypoint sh`), Telegram-методы и `wait_join_delay` замоканы, оценка — реальная (эвристика: `aiEnabled/mlEnabled=False`):
``` ```
SEARCH_OK # пустая система → none; поиск: кандидат добавлен, «уже мониторится» пропущен, «поиск завершён: 1 кандидатов» SEARCH_OK # пустая система → none; поиск: кандидат добавлен, «уже мониторится» пропущен, «поиск завершён: 1 кандидатов»
EVAL_REVIEW_OK # оценка: review, fitRatio 0.75, langRu=True EVAL_REVIEW_OK # оценка: review, fitRatio 0.75, langRu=True
EVAL_LANG_SKIP_OK # язык не русский → delete + skip EVAL_LANG_SKIP_OK # язык не русский → delete + skip
EVAL_FORUM_OK # форум: kind=forum, topics по темам (passed/нет), fitRatio агрегат EVAL_FORUM_OK # форум: kind=forum, topics по темам (passed/нет), fitRatio агрегат
EVAL_FEW_OK # <3 сообщений → review + «мало сообщений» EVAL_FEW_OK # <3 сообщений → review + «мало сообщений»
EVAL_NO_HISTORY_OK # история недоступна → review + «канал: история недоступна» + «язык не подтверждён» EVAL_NO_HISTORY_OK # история недоступна → review + «канал: история недоступна» + «язык не подтверждён»
AUTOJOIN_DONE_OK # join → joined/autoJoined + dialogs(monitor) + join_auto; план → done + лог done AUTOJOIN_DONE_OK # join → joined/autoJoined + dialogs(monitor) + join_auto; план → done + лог done
REJECT_RECHECK_OK # «вступили между оценкой и join» → mark_rejected + reject (без join) REJECT_RECHECK_OK # «вступили между оценкой и join» → mark_rejected + reject (без join)
TASK6_OFFLINE_OK TASK6_OFFLINE_OK
``` ```
4. Диагностика `discovery_worker.py` — без ошибок и предупреждений (ruff I/SIM/default — чисто; импорты/`_we_are_in`/`suppress` приведены к правилам). 4. Диагностика `discovery_worker.py` — без ошибок и предупреждений (ruff I/SIM/default — чисто; импорты/`_we_are_in`/`suppress` приведены к правилам).
## Concerns ## Concerns
1. Полный прогон на живом аккаунте — Task 10 вручную. Ветки `flood` и `error` (реальные FloodWaitError/сетевые ошибки join) офлайн не воспроизводятся — только код-ревью и логика Task 4 (`discovery_join` сам фиксирует flood и пробрасывает). 1. Полный прогон на живом аккаунте — Task 10 вручную. Ветки `flood` и `error` (реальные FloodWaitError/сетевые ошибки join) офлайн не воспроизводятся — только код-ревью и логика Task 4 (`discovery_join` сам фиксирует flood и пробрасывает).
2. Ветка «повторная проверка перед join» использует `mark_rejected`, который по контракту Task 3 добавляет источник в `disc_blacklist` — даже когда «уже вступили между оценкой и join». Для поиска это безвредно (источник и так отсекается по `dialogs`), но чёрный список формально пополняется. Если это нежелательно — можно ввести отдельный helper (интерфейсы Task 3 не менялись). 2. Ветка «повторная проверка перед join» использует `mark_rejected`, который по контракту Task 3 добавляет источник в `disc_blacklist` — даже когда «уже вступили между оценкой и join». Для поиска это безвредно (источник и так отсекается по `dialogs`), но чёрный список формально пополняется. Если это нежелательно — можно ввести отдельный helper (интерфейсы Task 3 не менялись).
3. `minSubscribers`-ветка «участники не подтверждены» помечает кандидата только когда минимум задан (`minSubscribers>0`); при `min=0` отсутствие participants не метка (участники не критерий). 3. `minSubscribers`-ветка «участники не подтверждены» помечает кандидата только когда минимум задан (`minSubscribers>0`); при `min=0` отсутствие participants не метка (участники не критерий).
4. Стоп-кран `discPaused` (`ban_guard.global_paused()`) останавливает весь tick — включая поиск и оценку, не только авто-join (по требованию задания). 4. Стоп-кран `discPaused` (`ban_guard.global_paused()`) останавливает весь tick — включая поиск и оценку, не только авто-join (по требованию задания).
5. Running-задача без работы (поиск завершён, кандидатов нет, `autoJoin=False`) остаётся running и даёт `{"action":"none"}` каждые 5 c — завершение/удаление такой задачи за пользователем (по брифу). 5. Running-задача без работы (поиск завершён, кандидатов нет, `autoJoin=False`) остаётся running и даёт `{"action":"none"}` каждые 5 c — завершение/удаление такой задачи за пользователем (по брифу).
6. `fitRatio` форума — агрегат по всей выборке (fit из N), а не максимум темы; вердикт форума — «есть ≥1 проходная тема». Формат зафиксирован в docstring и в этом отчёте (UI Task 9 показывает темы с per-topic X из N). 6. `fitRatio` форума — агрегат по всей выборке (fit из N), а не максимум темы; вердикт форума — «есть ≥1 проходная тема». Формат зафиксирован в docstring и в этом отчёте (UI Task 9 показывает темы с per-topic X из N).
@@ -1,23 +1,23 @@
### Task 7: API Discovery ### Task 7: API Discovery
**Files:** **Files:**
- Create: `backend/app/routers/discovery_routes.py` - Create: `backend/app/routers/discovery_routes.py`
- Modify: `backend/app/main.py` (регистрация роутера) - Modify: `backend/app/main.py` (регистрация роутера)
**Interfaces:** **Interfaces:**
- Prefix `/api/discovery`, auth `current_login`: - Prefix `/api/discovery`, auth `current_login`:
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/start`, `POST /tasks/{id}/pause` - `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`
- `POST /tasks/{id}/generate-keywords` — ИИ: промпт по description → JSON `{"keywords": [...]}` (816 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`. - `POST /tasks/{id}/generate-keywords` — ИИ: промпт по description → JSON `{"keywords": [...]}` (816 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
- `GET /tasks/{id}/candidates?status=` - `GET /tasks/{id}/candidates?status=`
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке. - `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке.
- `POST /candidates/{dialog_id}/reject``mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400. - `POST /candidates/{dialog_id}/reject``mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}` - `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
- `GET /tasks/{id}/log` - `GET /tasks/{id}/log`
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути). Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404). - [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
- [ ] **Step 2: Зарегистрировать в main.py**. - [ ] **Step 2: Зарегистрировать в main.py**.
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает). - [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
--- ---
@@ -1,52 +1,52 @@
# Task 7 — Отчёт: API Discovery # Task 7 — Отчёт: API Discovery
## Статус ## Статус
Выполнено. Роутер `/api/discovery` реализован, зарегистрирован в `main.py`, проверен на живом контейнере. Выполнено. Роутер `/api/discovery` реализован, зарегистрирован в `main.py`, проверен на живом контейнере.
## Файлы ## Файлы
- Создан: `backend/app/routers/discovery_routes.py` (prefix `/api/discovery`, tags `discovery`, auth `current_login`). - Создан: `backend/app/routers/discovery_routes.py` (prefix `/api/discovery`, tags `discovery`, auth `current_login`).
- Изменён: `backend/app/main.py` — импорт `discovery_routes` и добавление в цикл `include_router`. - Изменён: `backend/app/main.py` — импорт `discovery_routes` и добавление в цикл `include_router`.
## Что сделано ## Что сделано
### Эндпоинты ### Эндпоинты
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`; - `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`;
- `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`; - `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`;
- `POST /tasks/{id}/generate-keywords` — ИИ-генерация ключей по описанию задачи; - `POST /tasks/{id}/generate-keywords` — ИИ-генерация ключей по описанию задачи;
- `GET /tasks/{id}/candidates?status=new|review|joined|rejected` (необязателен; невалидный статус — 422 через `Literal`); - `GET /tasks/{id}/candidates?status=new|review|joined|rejected` (необязателен; невалидный статус — 422 через `Literal`);
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот/пауз воркера); - `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот/пауз воркера);
- `POST /candidates/{dialog_id}/reject` — отклонение с добавлением в чёрный список; - `POST /candidates/{dialog_id}/reject` — отклонение с добавлением в чёрный список;
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`; - `GET /blacklist`, `DELETE /blacklist/{dialog_id}`;
- `GET /tasks/{id}/log`. - `GET /tasks/{id}/log`.
### Модели и контракт ### Модели и контракт
- `TaskCreate` / `TaskPatch` — camelCase-поля без алиасов (как `PreviewBody` в `tg_routes`), необязательные поля `None` исключаются через `model_dump(exclude_none=True)`, чтобы `create_task`/`patch_task` сами подставляли дефолты (в т.ч. `threshold`/`sampleSize` из настроек). - `TaskCreate` / `TaskPatch` — camelCase-поля без алиасов (как `PreviewBody` в `tg_routes`), необязательные поля `None` исключаются через `model_dump(exclude_none=True)`, чтобы `create_task`/`patch_task` сами подставляли дефолты (в т.ч. `threshold`/`sampleSize` из настроек).
- `POST /tasks` отдаёт созданную задачу как есть (200); списки — `{"items": [...]}`; delete — `{"ok": true}`. - `POST /tasks` отдаёт созданную задачу как есть (200); списки — `{"items": [...]}`; delete — `{"ok": true}`.
- Обработка: `ValueError` → 400, `KeyError` → 404; для отсутствующих задачи/кандидата — явные 404-хелперы (`_task_or_404`, `_candidate_or_404`, чтение кандидата через `store: SELECT * FROM disc_candidates WHERE dialog_id=?` как в брифе). - Обработка: `ValueError` → 400, `KeyError` → 404; для отсутствующих задачи/кандидата — явные 404-хелперы (`_task_or_404`, `_candidate_or_404`, чтение кандидата через `store: SELECT * FROM disc_candidates WHERE dialog_id=?` как в брифе).
- `generate-keywords`: если `!aiEnabled` или у активного провайдера нет ключа (и не local) → `{"keywords": [], "error": "..."}` с HTTP 200; иначе `ai_service.chat_json(промпт RU+EN 1016, user=описание)``{"keywords": [...]}` (чистка: строки, без пустых/длинных/повторов, страховочный лимит 30); ошибка провайдера → `{"keywords": [], "error": str}`. Пустое описание → error-ветка. - `generate-keywords`: если `!aiEnabled` или у активного провайдера нет ключа (и не local) → `{"keywords": [], "error": "..."}` с HTTP 200; иначе `ai_service.chat_json(промпт RU+EN 1016, user=описание)``{"keywords": [...]}` (чистка: строки, без пустых/длинных/повторов, страховочный лимит 30); ошибка провайдера → `{"keywords": [], "error": str}`. Пустое описание → error-ветка.
- `join`: статус `joined` → 400; `tg.discovery_join(username)``tg.add_dialog_monitored(...)``discovery.mark_joined(auto=False)`; ошибка Telegram → 400 с текстом. - `join`: статус `joined` → 400; `tg.discovery_join(username)``tg.add_dialog_monitored(...)``discovery.mark_joined(auto=False)`; ошибка Telegram → 400 с текстом.
- `reject`: статус `joined` → 400 «Уже вступили — удалите источник из каналов»; иначе `mark_rejected(reason="отклонено вручную")`. - `reject`: статус `joined` → 400 «Уже вступили — удалите источник из каналов»; иначе `mark_rejected(reason="отклонено вручную")`.
- Роутер чисто проходит `ruff check` и `py_compile`. - Роутер чисто проходит `ruff check` и `py_compile`.
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase && python -m py_compile backend/app/routers/discovery_routes.py backend/app/main.py``PY_COMPILE_OK`; `ruff check backend/app/routers/discovery_routes.py` → clean. 1. `cd /c/telbase && python -m py_compile backend/app/routers/discovery_routes.py backend/app/main.py``PY_COMPILE_OK`; `ruff check backend/app/routers/discovery_routes.py` → clean.
2. `docker compose build app``Image telbase-app Built`; `docker compose up -d app` → контейнер пересоздан, `/api/health` → 200. 2. `docker compose build app``Image telbase-app Built`; `docker compose up -d app` → контейнер пересоздан, `/api/health` → 200.
3. Живой API (логин admin/admin, куки): 3. Живой API (логин admin/admin, куки):
- `POST /api/discovery/tasks {"name":"","planJoins":1}`**400** `{"detail":"Укажите название задачи"}`; - `POST /api/discovery/tasks {"name":"","planJoins":1}`**400** `{"detail":"Укажите название задачи"}`;
- `POST /api/discovery/tasks` корректная (plan=1, ключи пустые) → **200**, задача `status:"draft"`, дефолты `threshold:40/sampleSize:10` подставлены; - `POST /api/discovery/tasks` корректная (plan=1, ключи пустые) → **200**, задача `status:"draft"`, дефолты `threshold:40/sampleSize:10` подставлены;
- `GET /api/discovery/tasks`**200** `{"items":[задача]}`; - `GET /api/discovery/tasks`**200** `{"items":[задача]}`;
- `GET /api/discovery/tasks/{id}/candidates?status=review`**200** `{"items":[]}`; `GET /api/discovery/tasks/{id}/log`**200** `{"items":[]}`; `GET /api/discovery/blacklist`**200** `{"items":[]}`; - `GET /api/discovery/tasks/{id}/candidates?status=review`**200** `{"items":[]}`; `GET /api/discovery/tasks/{id}/log`**200** `{"items":[]}`; `GET /api/discovery/blacklist`**200** `{"items":[]}`;
- `POST /api/discovery/tasks/{id}/generate-keywords`**200** (в этом окружении ключ ИИ настроен и `aiEnabled=true`) → реальный вызов провайдера, ответ `{"keywords":[14 строк RU+EN]}` (happy path); - `POST /api/discovery/tasks/{id}/generate-keywords`**200** (в этом окружении ключ ИИ настроен и `aiEnabled=true`) → реальный вызов провайдера, ответ `{"keywords":[14 строк RU+EN]}` (happy path);
- error-ветка: временно `PATCH /api/settings {"aiEnabled":false}``generate-keywords`**200** `{"keywords":[],"error":"ИИ выключен в настройках (aiEnabled)"}`; настройка возвращена в `true`; - error-ветка: временно `PATCH /api/settings {"aiEnabled":false}``generate-keywords`**200** `{"keywords":[],"error":"ИИ выключен в настройках (aiEnabled)"}`; настройка возвращена в `true`;
- `POST /api/discovery/tasks/{id}/start` при пустых ключах → **400** `{"detail":"Нет ключевых слов для поиска — добавьте их в задачу"}`; - `POST /api/discovery/tasks/{id}/start` при пустых ключах → **400** `{"detail":"Нет ключевых слов для поиска — добавьте их в задачу"}`;
- `PATCH /api/discovery/tasks/{id}` (name+keywords) → **200**, поля обновлены; `POST .../pause`**200** `status:"paused"`; - `PATCH /api/discovery/tasks/{id}` (name+keywords) → **200**, поля обновлены; `POST .../pause`**200** `status:"paused"`;
- `DELETE /api/discovery/tasks/{id}`**200** `{"ok":true}`; повторный `GET /tasks`**200** `{"items":[]}`; - `DELETE /api/discovery/tasks/{id}`**200** `{"ok":true}`; повторный `GET /tasks`**200** `{"items":[]}`;
- `start`/`PATCH`/`candidates` по несуществующей задаче → **404** `{"detail":"Задача не найдена"}`; - `start`/`PATCH`/`candidates` по несуществующей задаче → **404** `{"detail":"Задача не найдена"}`;
- `POST /api/discovery/candidates/{id}/join` и `/reject` по несуществующему кандидату → **404** `{"detail":"Кандидат не найден"}`; - `POST /api/discovery/candidates/{id}/join` и `/reject` по несуществующему кандидату → **404** `{"detail":"Кандидат не найден"}`;
- `GET /api/discovery/tasks/{id}/candidates?status=bogus`**422** (валидация `Literal`); - `GET /api/discovery/tasks/{id}/candidates?status=bogus`**422** (валидация `Literal`);
- `DELETE /api/discovery/blacklist/{id}` (нет записи) → **200** `{"ok":true}`. - `DELETE /api/discovery/blacklist/{id}` (нет записи) → **200** `{"ok":true}`.
## Concerns ## Concerns
1. Ветки `join`/`reject` с реальным кандидатом и реальным `tg.discovery_join` (в т.ч. «уже joined» → 400 и ошибка Telegram → 400) живьём не гонялись — нужен подключённый Telegram-аккаунт и настоящий кандидат; это ручная проверка уровня Task 10. Контрактные 404/422 проверены. 1. Ветки `join`/`reject` с реальным кандидатом и реальным `tg.discovery_join` (в т.ч. «уже joined» → 400 и ошибка Telegram → 400) живьём не гонялись — нужен подключённый Telegram-аккаунт и настоящий кандидат; это ручная проверка уровня Task 10. Контрактные 404/422 проверены.
2. `generate-keywords` в проверке реально дёрнул настроенного провайдера (сетевой вызов). Error-ветка проверена переключением `aiEnabled`; ветка «ключ не задан» воспроизводится так же, но отдельно не гонялась, чтобы не трогать `aiConfigs`. 2. `generate-keywords` в проверке реально дёрнул настроенного провайдера (сетевой вызов). Error-ветка проверена переключением `aiEnabled`; ветка «ключ не задан» воспроизводится так же, но отдельно не гонялась, чтобы не трогать `aiConfigs`.
3. В `main.py` остались pre-existing предупреждения ruff (не связаны с задачей): неиспользуемый импорт `pathlib.Path` (F401) и серия `# noqa: BLE001` на голых `except Exception:` без `as` (RUF100 — ruff не считает BLE001 срабатывающим на таких обработчиках). Не правил: файл вне объёма, `py_compile` чист. 3. В `main.py` остались pre-existing предупреждения ruff (не связаны с задачей): неиспользуемый импорт `pathlib.Path` (F401) и серия `# noqa: BLE001` на голых `except Exception:` без `as` (RUF100 — ruff не считает BLE001 срабатывающим на таких обработчиках). Не правил: файл вне объёма, `py_compile` чист.
4. Под Windows/MSYS кириллица в `curl -d '...'` ломает тело запроса («There was an error parsing the body») — проверки с кириллицей делались через `--data @файл` (UTF-8). К продакшену отношения не имеет. 4. Под Windows/MSYS кириллица в `curl -d '...'` ломает тело запроса («There was an error parsing the body») — проверки с кириллицей делались через `--data @файл` (UTF-8). К продакшену отношения не имеет.
@@ -1,17 +1,17 @@
### Task 8: Фронтенд — store + каркас подвкладки «Поиск» ### Task 8: Фронтенд — store + каркас подвкладки «Поиск»
**Files:** **Files:**
- Modify: `frontend/src/store.js` - Modify: `frontend/src/store.js`
- Create: `frontend/src/views/DiscoveryView.vue` - Create: `frontend/src/views/DiscoveryView.vue`
- Modify: `frontend/src/views/ChannelsView.vue` - Modify: `frontend/src/views/ChannelsView.vue`
**Interfaces:** **Interfaces:**
- state: `channelsTab: 'list' | 'search'`, `discTasks: []`, `discCandidates: []`, `discBlacklist: []`, `discLog: []`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`. - state: `channelsTab: 'list' | 'search'`, `discTasks: []`, `discCandidates: []`, `discBlacklist: []`, `discLog: []`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`.
- store-функции: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)` (create/patch), `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`. - store-функции: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)` (create/patch), `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
- [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`). - [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`).
- [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу. - [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу.
- [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»). - [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
- [ ] **Step 4: `npm run build`** — без ошибок. - [ ] **Step 4: `npm run build`** — без ошибок.
--- ---
@@ -1,46 +1,46 @@
# Task 8 — Отчёт: Фронтенд — store + каркас подвкладки «Поиск» # Task 8 — Отчёт: Фронтенд — store + каркас подвкладки «Поиск»
## Статус ## Статус
Выполнено. `npm run build` проходит без ошибок. Выполнено. `npm run build` проходит без ошибок.
## Файлы ## Файлы
- Изменён: `frontend/src/store.js` — состояние Discovery, сброс при разлогине, 13 функций. - Изменён: `frontend/src/store.js` — состояние Discovery, сброс при разлогине, 13 функций.
- Изменён: `frontend/src/views/ChannelsView.vue` — сегмент «Каналы | Поиск каналов» и рендер по табу. - Изменён: `frontend/src/views/ChannelsView.vue` — сегмент «Каналы | Поиск каналов» и рендер по табу.
- Создан: `frontend/src/views/DiscoveryView.vue` — список задач + панель активной задачи/мастер. - Создан: `frontend/src/views/DiscoveryView.vue` — список задач + панель активной задачи/мастер.
## Что сделано ## Что сделано
### store.js ### store.js
- Состояние (по интерфейсу брифа): `channelsTab: 'list' | 'search'` (init `'list'`), `discTasks`, `discCandidates`, `discBlacklist`, `discLog`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`. Добавлено в `state` (блок «Каналы») и в `resetLocal()` (разлогин → чистый Discovery, `channelsTab` возвращается в `'list'`). - Состояние (по интерфейсу брифа): `channelsTab: 'list' | 'search'` (init `'list'`), `discTasks`, `discCandidates`, `discBlacklist`, `discLog`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`. Добавлено в `state` (блок «Каналы») и в `resetLocal()` (разлогин → чистый Discovery, `channelsTab` возвращается в `'list'`).
- Функции названы точно по брифу: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)`, `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`. - Функции названы точно по брифу: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)`, `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
- Контракт в camelCase как в API: `minSubscribers`, `sampleSize`, `planJoins`, `autoJoin`, `dialogId` и т.д. Тела задач собираются в camelCase (`POST/PATCH /api/discovery/tasks`), списки читаются из `{"items": [...]}`. - Контракт в camelCase как в API: `minSubscribers`, `sampleSize`, `planJoins`, `autoJoin`, `dialogId` и т.д. Тела задач собираются в camelCase (`POST/PATCH /api/discovery/tasks`), списки читаются из `{"items": [...]}`.
- Паттерны проекта: `api.get/post/patch/delete`, `toast`/`errMsg` на действиях; тихие `catch → false` на фоновых чтениях списков (как `loadPipelineQueue`/`loadRejected`). - Паттерны проекта: `api.get/post/patch/delete`, `toast`/`errMsg` на действиях; тихие `catch → false` на фоновых чтениях списков (как `loadPipelineQueue`/`loadRejected`).
- `saveDiscTask` — создаёт/патчит, кладёт задачу в `discTasks`, ставит `discActiveTaskId`, возвращает задачу или `null`. - `saveDiscTask` — создаёт/патчит, кладёт задачу в `discTasks`, ставит `discActiveTaskId`, возвращает задачу или `null`.
- `generateDiscKeywords`**error-ветка API** (`{"keywords": [], "error": "..."}`) → `toast(error)`, возвращает `null`; успех → массив `keywords` (может быть пустым). - `generateDiscKeywords`**error-ветка API** (`{"keywords": [], "error": "..."}`) → `toast(error)`, возвращает `null`; успех → массив `keywords` (может быть пустым).
- `deleteDiscTask` — после удаления активной задачи выбирает первую оставшуюся. - `deleteDiscTask` — после удаления активной задачи выбирает первую оставшуюся.
- `loadDiscTasks` — сохраняет активную задачу, если она ещё существует, иначе выбирает первую (правая панель не пустует). - `loadDiscTasks` — сохраняет активную задачу, если она ещё существует, иначе выбирает первую (правая панель не пустует).
- `join/rejectDiscCandidate` — на успехе убирают кандидата из текущего списка `discCandidates` + toast; `removeDiscBlacklist` фильтрует по `dialogId`. Поля кандидата/блэклиста не используются в UI до Task 9. - `join/rejectDiscCandidate` — на успехе убирают кандидата из текущего списка `discCandidates` + toast; `removeDiscBlacklist` фильтрует по `dialogId`. Поля кандидата/блэклиста не используются в UI до Task 9.
### ChannelsView.vue ### ChannelsView.vue
- В шапке — сегмент «Каналы | Поиск каналов» (мелкие кнопки в контейнере `bg-ink/60 border border-white/5`, активный — `bg-white/8 text-hi`), переключение через `gotoChannelsTab`. Подзаголовок шапки и правые действия («Перечитать», «Включить все», поиск по имени) показываются только на табе `'list'`. - В шапке — сегмент «Каналы | Поиск каналов» (мелкие кнопки в контейнере `bg-ink/60 border border-white/5`, активный — `bg-white/8 text-hi`), переключение через `gotoChannelsTab`. Подзаголовок шапки и правые действия («Перечитать», «Включить все», поиск по имени) показываются только на табе `'list'`.
- Синхронизация списка каналов (watch по `state.view` + `onMounted`) ограничена условием `channelsTab === 'list'`; добавлен отдельный watch по `channelsTab` — возврат на таб «Каналы» освежает список. - Синхронизация списка каналов (watch по `state.view` + `onMounted`) ограничена условием `channelsTab === 'list'`; добавлен отдельный watch по `channelsTab` — возврат на таб «Каналы» освежает список.
- При `channelsTab === 'search'` вместо списка каналов рендерится `<DiscoveryView/>` (импорт из `./DiscoveryView.vue`). - При `channelsTab === 'search'` вместо списка каналов рендерится `<DiscoveryView/>` (импорт из `./DiscoveryView.vue`).
### DiscoveryView.vue (каркас: задачи + мастер) ### DiscoveryView.vue (каркас: задачи + мастер)
- Слева: колонка `w-[300px]` со списком задач (`state.discTasks`; имя + чип статуса + ключи/найдено/вступили), кнопка «Новая задача», refresh. Пустое состояние — с подсказкой. - Слева: колонка `w-[300px]` со списком задач (`state.discTasks`; имя + чип статуса + ключи/найдено/вступили), кнопка «Новая задача», refresh. Пустое состояние — с подсказкой.
- Справа: панель активной задачи (`state.discActiveTaskId`). Нет выбора — приветственный экран с кнопкой «Новая задача». - Справа: панель активной задачи (`state.discActiveTaskId`). Нет выбора — приветственный экран с кнопкой «Новая задача».
- Мастер: `name`, `description`, кнопка «Сгенерировать ключи ИИ» (иконка sparkles), редактируемые чипы ключей (ввод + Enter/плюс, удаление крестиком), числовые поля `minSubscribers`/`threshold`/`sampleSize`/`planJoins` (дефолты 0/40/10/1), `lang` select ru/any, toggle `autoJoin`. Стили в духе SettingsView: `rounded-xl border border-white/8 bg-raise/40 p-4`, инпуты `h-9 bg-ink/70 border-white/10 focus:border-brand/50`, чипы как у стоп-фраз/сферы. - Мастер: `name`, `description`, кнопка «Сгенерировать ключи ИИ» (иконка sparkles), редактируемые чипы ключей (ввод + Enter/плюс, удаление крестиком), числовые поля `minSubscribers`/`threshold`/`sampleSize`/`planJoins` (дефолты 0/40/10/1), `lang` select ru/any, toggle `autoJoin`. Стили в духе SettingsView: `rounded-xl border border-white/8 bg-raise/40 p-4`, инпуты `h-9 bg-ink/70 border-white/10 focus:border-brand/50`, чипы как у стоп-фраз/сферы.
- Кнопки: «Запустить» (градиент; disabled пока `keywords` пустые; подпись «Продолжить» для paused, «Запустить заново» для done/failed), «Сохранить», «Удалить» (с `askConfirm`, скрыта для черновика и на паузе выполнения), «Поставить на паузу» (только running). - Кнопки: «Запустить» (градиент; disabled пока `keywords` пустые; подпись «Продолжить» для paused, «Запустить заново» для done/failed), «Сохранить», «Удалить» (с `askConfirm`, скрыта для черновика и на паузе выполнения), «Поставить на паузу» (только running).
- Статус задачи: чип статуса (draft/running/paused/done/failed с цветами online/warn/brand/danger), при прогрессе — счётчики found/evaluated/joined/rejected, дата создания. Пока задача running — поля задизейблены (overlay) и список тихо опрашивается раз в 4.5 c (статус/счётчики живут на бэке у воркера). - Статус задачи: чип статуса (draft/running/paused/done/failed с цветами online/warn/brand/danger), при прогрессе — счётчики found/evaluated/joined/rejected, дата создания. Пока задача running — поля задизейблены (overlay) и список тихо опрашивается раз в 4.5 c (статус/счётчики живут на бэке у воркера).
- Без заглушек-обещаний: панель кандидатов/лога не рисовалась (ожидаемо — Task 9). - Без заглушек-обещаний: панель кандидатов/лога не рисовалась (ожидаемо — Task 9).
- UX-детали: «Запустить» и «Сгенерировать ключи ИИ» при необходимости сначала сохраняют форму (`saveDiscTask`), т.к. старт и генерация идут по id задачи на сервере; черновик «Новой задачи» не затирается ручным refresh списка. - UX-детали: «Запустить» и «Сгенерировать ключи ИИ» при необходимости сначала сохраняют форму (`saveDiscTask`), т.к. старт и генерация идут по id задачи на сервере; черновик «Новой задачи» не затирается ручным refresh списка.
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase/frontend && npm run build``✓ built in 1.32s`, `42 modules transformed`, ошибок нет. 1. `cd /c/telbase/frontend && npm run build``✓ built in 1.32s`, `42 modules transformed`, ошибок нет.
2. `diagnostics` по `store.js`, `ChannelsView.vue`, `DiscoveryView.vue` — ошибок/предупреждений нет. 2. `diagnostics` по `store.js`, `ChannelsView.vue`, `DiscoveryView.vue` — ошибок/предупреждений нет.
## Concerns ## Concerns
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение функций опирается на контракт Task 7 (`{"items": [...]}`, camelCase, 200/400/404). Ручная проверка сценариев — за Task 10. 1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение функций опирается на контракт Task 7 (`{"items": [...]}`, camelCase, 200/400/404). Ручная проверка сценариев — за Task 10.
2. Генерация ключей ИИ по API идёт от id задачи (`POST /tasks/{id}/generate-keywords`), поэтому при генерации из нового черновика кнопка сначала создаёт задачу (нужно название — при пустом показывается toast «Укажите название задачи»). После генерации ключи кладутся в форму, в БД фиксируются кнопкой «Сохранить» (или автоматически при «Запустить»). 2. Генерация ключей ИИ по API идёт от id задачи (`POST /tasks/{id}/generate-keywords`), поэтому при генерации из нового черновика кнопка сначала создаёт задачу (нужно название — при пустом показывается toast «Укажите название задачи»). После генерации ключи кладутся в форму, в БД фиксируются кнопкой «Сохранить» (или автоматически при «Запустить»).
3. Дефолты формы (threshold 40 / sampleSize 10) повторяют дефолты сервиса; при создании бэкенд может подставить значения настроек `discEvalThreshold/discEvalSample` — после «Сохранить» форма перечитывается из ответа сервера (`pickFromTask`), так что расхождение схлопывается. 3. Дефолты формы (threshold 40 / sampleSize 10) повторяют дефолты сервиса; при создании бэкенд может подставить значения настроек `discEvalThreshold/discEvalSample` — после «Сохранить» форма перечитывается из ответа сервера (`pickFromTask`), так что расхождение схлопывается.
4. `channelsTab` глобальный и переживает уход/возврат на экран «Каналы» (как `settingsTab`); активная задача Discovery тоже сохраняется между визитами, при её удалении/исчезновении выбирается первая. 4. `channelsTab` глобальный и переживает уход/возврат на экран «Каналы» (как `settingsTab`); активная задача Discovery тоже сохраняется между визитами, при её удалении/исчезновении выбирается первая.
5. Опрос статуса running-задачи — простой `setInterval` 4.5 c на время нахождения на вкладке; при большом числе задач можно позже перевести на точечный `GET /tasks/{id}` или SSE. 5. Опрос статуса running-задачи — простой `setInterval` 4.5 c на время нахождения на вкладке; при большом числе задач можно позже перевести на точечный `GET /tasks/{id}` или SSE.
@@ -1,15 +1,15 @@
### Task 9: Фронтенд — кандидаты, действия, чёрный список, история ### Task 9: Фронтенд — кандидаты, действия, чёрный список, история
**Files:** **Files:**
- Modify: `frontend/src/views/DiscoveryView.vue` - Modify: `frontend/src/views/DiscoveryView.vue`
**Interfaces:** **Interfaces:**
- Consumes: Task 8 (store). - Consumes: Task 8 (store).
- [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0. - [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0.
- [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»). - [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
- [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings). - [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings).
- [ ] **Step 4: История** — лог задачи. - [ ] **Step 4: История** — лог задачи.
- [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10). - [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10).
--- ---
@@ -1,49 +1,49 @@
# Task 9 — Отчёт: Фронтенд — кандидаты, действия, чёрный список, история # Task 9 — Отчёт: Фронтенд — кандидаты, действия, чёрный список, история
## Статус ## Статус
Выполнено. `cd /c/telbase/frontend && npm run build` — без ошибок (`✓ built`, 42 modules transformed). Выполнено. `cd /c/telbase/frontend && npm run build` — без ошибок (`✓ built`, 42 modules transformed).
`python -m py_compile backend/app/routers/settings_routes.py` — OK. `python -m py_compile backend/app/routers/settings_routes.py` — OK.
## Файлы ## Файлы
- Изменён: `frontend/src/views/DiscoveryView.vue` — табы кандидатов/истории/чёрного списка, карточки кандидатов, квоты. - Изменён: `frontend/src/views/DiscoveryView.vue` — табы кандидатов/истории/чёрного списка, карточки кандидатов, квоты.
- Изменён: `frontend/src/store.js``state.discCounts` + `loadDiscCounts(taskId)`; `loadDiscCandidates` обновляет счётчик текущего статуса. - Изменён: `frontend/src/store.js``state.discCounts` + `loadDiscCounts(taskId)`; `loadDiscCandidates` обновляет счётчик текущего статуса.
- Изменён: `frontend/src/components/Icon.vue` — новые иконки `users`, `megaphone`, `list` (чипы вида источника, участники, темы). - Изменён: `frontend/src/components/Icon.vue` — новые иконки `users`, `megaphone`, `list` (чипы вида источника, участники, темы).
- Изменён: `backend/app/routers/settings_routes.py``discPaused` добавлен в `_PUBLIC_BOOL` (одной строкой). - Изменён: `backend/app/routers/settings_routes.py``discPaused` добавлен в `_PUBLIC_BOOL` (одной строкой).
## Что сделано ## Что сделано
### Табы панели задачи (DiscoveryView, правая колонка) ### Табы панели задачи (DiscoveryView, правая колонка)
- Под «мастером» и действиями задачи — панель с табами: «В обработке» (`new`), «На рассмотрении» (`review`), «Вступили» (`joined`), «Отклонены» (`rejected`), «История» (лог), «Чёрный список». - Под «мастером» и действиями задачи — панель с табами: «В обработке» (`new`), «На рассмотрении» (`review`), «Вступили» (`joined`), «Отклонены» (`rejected`), «История» (лог), «Чёрный список».
- Бейджи-счётчики (`state.discCounts`) скрыты при 0; переключение таба вызывает `loadDiscCandidates(taskId, status)` / `loadDiscLog(taskId)` / `loadDiscBlacklist()`; смена активной задачи и повторный клик по ней — авто-загрузка панели (`loadPanel`). - Бейджи-счётчики (`state.discCounts`) скрыты при 0; переключение таба вызывает `loadDiscCandidates(taskId, status)` / `loadDiscLog(taskId)` / `loadDiscBlacklist()`; смена активной задачи и повторный клик по ней — авто-загрузка панели (`loadPanel`).
- Счётчики всех четырёх статусов обновляет `loadDiscCounts` (4 параллельных GET по статусам). Защита от гонок при быстром переключении табов/задач — `panelSeq` (stale-ответ перезагружает актуальный таб). - Счётчики всех четырёх статусов обновляет `loadDiscCounts` (4 параллельных GET по статусам). Защита от гонок при быстром переключении табов/задач — `panelSeq` (stale-ответ перезагружает актуальный таб).
- Пока задача `running` — список кандидатов/счётчики тихо обновляются каждый второй тик опроса (~9 c). - Пока задача `running` — список кандидатов/счётчики тихо обновляются каждый второй тик опроса (~9 c).
### Карточка кандидата ### Карточка кандидата
- Аватар по `hue` (как в ChannelsView), название, `@username`, чип вида (канал `megaphone`/brand, группа `users`/online, форум `list`/warn), участники (`N участник/а/ов`, «—» при None), метки `marks` чипами (важные — закрытая группа/история недоступна — подсвечиваются warn). - Аватар по `hue` (как в ChannelsView), название, `@username`, чип вида (канал `megaphone`/brand, группа `users`/online, форум `list`/warn), участники (`N участник/а/ов`, «—» при None), метки `marks` чипами (важные — закрытая группа/история недоступна — подсвечиваются warn).
- Соответствие: для канала/группы — «подходит N%» (по `fitRatio`); для форума — «подходит тем: N из M» и раскрывающийся список тем «тема „{title}" — подходит {fitCount} из {total}» с passed-подсветкой (pass — online, нет — приглушённый). - Соответствие: для канала/группы — «подходит N%» (по `fitRatio`); для форума — «подходит тем: N из M» и раскрывающийся список тем «тема „{title}" — подходит {fitCount} из {total}» с passed-подсветкой (pass — online, нет — приглушённый).
- Кнопки «Вступить и мониторить» и «Отклонить» — только для `status === 'review'`; join без подтверждения, reject через `askConfirm` (уходит в чёрный список). Для `new` кнопок нет — подпись «воркер оценит источник». После join/reject счётчики перечитываются. - Кнопки «Вступить и мониторить» и «Отклонить» — только для `status === 'review'`; join без подтверждения, reject через `askConfirm` (уходит в чёрный список). Для `new` кнопок нет — подпись «воркер оценит источник». После join/reject счётчики перечитываются.
- Раскрытие тем форума — локальный `Set` `expanded` (chevron). - Раскрытие тем форума — локальный `Set` `expanded` (chevron).
### Чёрный список ### Чёрный список
- Выбран отдельный таб «Чёрный список» в той же панели (читабельно и не спорит с макетом). Строка: иконка, имя, причина/дата, кнопка «Снять» (`removeDiscBlacklist`, guard от двойного клика `unbanId`). - Выбран отдельный таб «Чёрный список» в той же панели (читабельно и не спорит с макетом). Строка: иконка, имя, причина/дата, кнопка «Снять» (`removeDiscBlacklist`, guard от двойного клика `unbanId`).
### Квоты авто-вступлений ### Квоты авто-вступлений
- Маленькая кнопка «Квоты» в шапке левой панели (рядом со списком задач) → inline-блок: суточный лимит `discJoinLimit`, паузы мин/макс `discJoinDelayMin/Max` (сек), стоп-кран-переключатель `discPaused`. - Маленькая кнопка «Квоты» в шапке левой панели (рядом со списком задач) → inline-блок: суточный лимит `discJoinLimit`, паузы мин/макс `discJoinDelayMin/Max` (сек), стоп-кран-переключатель `discPaused`.
- Дефолты не хардкодятся: при первом открытии блока — `GET /api/settings` (локальная загрузка), сохранение чисел — `PATCH /api/settings` одним объектом (клампы 1–200 и 5–600 как на бэке, min ≤ max), стоп-кран патчится сразу при переключении. - Дефолты не хардкодятся: при первом открытии блока — `GET /api/settings` (локальная загрузка), сохранение чисел — `PATCH /api/settings` одним объектом (клампы 1–200 и 5–600 как на бэке, min ≤ max), стоп-кран патчится сразу при переключении.
- На бэке `discPaused` добавлен в `_PUBLIC_BOOL` — теперь принимается PATCH и отдаётся в GET. - На бэке `discPaused` добавлен в `_PUBLIC_BOOL` — теперь принимается PATCH и отдаётся в GET.
### store.js ### store.js
- `state.discCounts = { new:0, review:0, joined:0, rejected:0 }`. - `state.discCounts = { new:0, review:0, joined:0, rejected:0 }`.
- `loadDiscCandidates` дополнительно пишет `discCounts[status]`. - `loadDiscCandidates` дополнительно пишет `discCounts[status]`.
- `loadDiscCounts(taskId)` — Promise.all по 4 статусам, заполняет `discCounts` (не трогает `discCandidates`). - `loadDiscCounts(taskId)` — Promise.all по 4 статусам, заполняет `discCounts` (не трогает `discCandidates`).
## Вывод проверок ## Вывод проверок
1. `cd /c/telbase/frontend && npm run build``✓ built in 1.24s`, `42 modules transformed`, ошибок нет. 1. `cd /c/telbase/frontend && npm run build``✓ built in 1.24s`, `42 modules transformed`, ошибок нет.
2. `diagnostics` по `DiscoveryView.vue`, `store.js`, `Icon.vue` — ошибок/предупреждений нет. 2. `diagnostics` по `DiscoveryView.vue`, `store.js`, `Icon.vue` — ошибок/предупреждений нет.
3. `python -m py_compile backend/app/routers/settings_routes.py` → OK. 3. `python -m py_compile backend/app/routers/settings_routes.py` → OK.
## Concerns ## Concerns
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение опирается на контракт Task 7. Визуальная проверка сценариев — за Task 10. 1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение опирается на контракт Task 7. Визуальная проверка сценариев — за Task 10.
2. Кнопка «Квоты» дергает `GET/PATCH /api/settings` напрямую из вью (в store нет state-полей под диск-квоты, по брифу разрешена локальная загрузка/сохранение через `api.patch`). Это единственное место, где вью импортирует `api` напрямую — если захочется строгой «только через store», квоты стоит перевести в store-функции. 2. Кнопка «Квоты» дергает `GET/PATCH /api/settings` напрямую из вью (в store нет state-полей под диск-квоты, по брифу разрешена локальная загрузка/сохранение через `api.patch`). Это единственное место, где вью импортирует `api` напрямую — если захочется строгой «только через store», квоты стоит перевести в store-функции.
3. Счётчики табов получаются отдельными запросами по каждому статусу (у бэкенда нет эндпоинта counts); при активном воркере это ~4 лёгких GET каждые ~9 c — приемлемо для текущих объёмов, но при росте числа кандидатов можно добавить серверный `counts`. 3. Счётчики табов получаются отдельными запросами по каждому статусу (у бэкенда нет эндпоинта counts); при активном воркере это ~4 лёгких GET каждые ~9 c — приемлемо для текущих объёмов, но при росте числа кандидатов можно добавить серверный `counts`.
4. «На рассмотрении» — таб по умолчанию для активной задачи (в `state.discCandidateStatus` изначально `review`); таб «История»/«Чёрный список» сохраняется при переключении задач. 4. «На рассмотрении» — таб по умолчанию для активной задачи (в `state.discCandidateStatus` изначально `review`); таб «История»/«Чёрный список» сохраняется при переключении задач.
5. `progress.md` не трогался. 5. `progress.md` не трогался.
@@ -1,23 +0,0 @@
# 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` — зелёный.
- Фронт содержательно не менялся.
+53 -53
View File
@@ -1,53 +1,53 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-scaffold.md # SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-scaffold.md
Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger. Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger.
Ревью выполняется по фактическим файлам дерева (пакеты diff недоступны без git). Ревью выполняется по фактическим файлам дерева (пакеты diff недоступны без git).
## Todos ## Todos
- [x] Task 1: Структура src/ и перенос фронтенда - [x] Task 1: Структура src/ и перенос фронтенда
- [x] Task 2: Стандарты кода — .editorconfig, Directory.Build.props - [x] Task 2: Стандарты кода — .editorconfig, Directory.Build.props
- [x] Task 3: Решение Deal.sln и пустые проекты core - [x] Task 3: Решение Deal.sln и пустые проекты core
- [x] Task 4: Тесты — xUnit-каркас - [x] Task 4: Тесты — xUnit-каркас
- [x] Task 5: Dev-Postgres в docker compose (схема на тенанта) - [x] Task 5: Dev-Postgres в docker compose (схема на тенанта)
- [x] Task 6: Tenant-контекст и подключение к Postgres - [x] Task 6: Tenant-контекст и подключение к Postgres
- [x] Task 7: EF Core + миграции (public) - [x] Task 7: EF Core + миграции (public)
- [x] Task 8: Применение миграций ко всем схемам тенантов - [x] Task 8: Применение миграций ко всем схемам тенантов
- [x] Task 9: CI-скрипты и финальная проверка этапа - [x] Task 9: CI-скрипты и финальная проверка этапа
## Pre-flight scan (таблица пар задач и внутренней согласованности) ## Pre-flight scan (таблица пар задач и внутренней согласованности)
| Пара | Производит / потребляет | Результат | | Пара | Производит / потребляет | Результат |
|---|---|---| |---|---|---|
| T1 → T5 | T1 создаёт корневой README.md; T5 его дополняет | Чисто | | T1 → T5 | T1 создаёт корневой README.md; T5 его дополняет | Чисто |
| T1 → T2 | T1 создаёт src/core; T2 кладёт Directory.Build.props в src/core | Чисто | | T1 → T2 | T1 создаёт src/core; T2 кладёт Directory.Build.props в src/core | Чисто |
| T2 → T3..T9 | props применяется ко всем csproj под src/core (вкл. тесты, Api) | Внутреннее расхождение: пакет-анализатор 9.0.0 против SDK 10 — см. Ruling 1 | | T2 → T3..T9 | props применяется ко всем csproj под src/core (вкл. тесты, Api) | Внутреннее расхождение: пакет-анализатор 9.0.0 против SDK 10 — см. Ruling 1 |
| T3 → T4 | T4 ссылается на проекты модулей из T3 | Чисто | | T3 → T4 | T4 ссылается на проекты модулей из T3 | Чисто |
| T4 → T6/T7/T8 | T4 создаёт тестовый проект; T6 добавляет TenantIdTests, T7 TenantEntityTests, T8 TenantSchemaMigratorTests | T7/T8 тестам нужен reference на Deal.Infrastructure, которого нет в T4 — см. Ruling 3 | | T4 → T6/T7/T8 | T4 создаёт тестовый проект; T6 добавляет TenantIdTests, T7 TenantEntityTests, T8 TenantSchemaMigratorTests | T7/T8 тестам нужен reference на Deal.Infrastructure, которого нет в T4 — см. Ruling 3 |
| T4/T6 | счётчики тестов: T4=1 (MarkerTests), +T6=2 → 3 PASS | Чисто (внутренне согласовано в T6 Step 3) | | T4/T6 | счётчики тестов: T4=1 (MarkerTests), +T6=2 → 3 PASS | Чисто (внутренне согласовано в T6 Step 3) |
| T6 → T7 | ConnectionStringProvider в Infrastructure зависит от IConfiguration | Внутреннее расхождение: в плане нет пакета конфигурации для Infrastructure — см. Ruling 4 | | T6 → T7 | ConnectionStringProvider в Infrastructure зависит от IConfiguration | Внутреннее расхождение: в плане нет пакета конфигурации для Infrastructure — см. Ruling 4 |
| T6 → T6 | ITenantContext/AsyncLocal, SqlSchema search_path | Чисто | | T6 → T6 | ITenantContext/AsyncLocal, SqlSchema search_path | Чисто |
| T7 → T8 | T8 консьюмит TenantId; отдельно SQL | Чисто | | T7 → T8 | T8 консьюмит TenantId; отдельно SQL | Чисто |
| T7 | dotnet-ef миграция требует tool | Ruling 5 (локальный tool-manifest вместо --global) | | T7 | dotnet-ef миграция требует tool | Ruling 5 (локальный tool-manifest вместо --global) |
| T7 | IDesignTimeDbContextFactory в Infrastructure; API как startup | Чисто | | T7 | IDesignTimeDbContextFactory в Infrastructure; API как startup | Чисто |
| T2 → T3 | warnings-as-errors на новых шаблонах .NET 10 | Риск: неожиданные NETSDK-предупреждения; при необходимости ослабить severity в .editorconfig (записано в Ruling 1) | | T2 → T3 | warnings-as-errors на новых шаблонах .NET 10 | Риск: неожиданные NETSDK-предупреждения; при необходимости ослабить severity в .editorconfig (записано в Ruling 1) |
| T1 | `cp -r frontend/*` тянет node_modules/dist | Приемлемо (без git всё хранится в дереве); проверить копию ДО rm (Ruling 2) | | T1 | `cp -r frontend/*` тянет node_modules/dist | Приемлемо (без git всё хранится в дереве); проверить копию ДО rm (Ruling 2) |
## Rulings (pre-flight) ## Rulings (pre-flight)
- **Ruling 1** — план (T2) включает `Microsoft.CodeAnalysis.NetAnalyzers 9.0.0`; SDK 10.0.400 уже поставляет совместимые анализаторы (AnalysisLevel=latest + EnforceCodeStyleInBuild). Явный пакет 9.0.0 рискует версионным рассинхроном с net10 и ошибками сборки при warnings-as-errors. Решение: явный PackageReference НЕ добавляем, полагаемся на встроенные анализаторы SDK. Стоимость при ошибке: вернуть пакет позже одной строкой. - **Ruling 1** — план (T2) включает `Microsoft.CodeAnalysis.NetAnalyzers 9.0.0`; SDK 10.0.400 уже поставляет совместимые анализаторы (AnalysisLevel=latest + EnforceCodeStyleInBuild). Явный пакет 9.0.0 рискует версионным рассинхроном с net10 и ошибками сборки при warnings-as-errors. Решение: явный PackageReference НЕ добавляем, полагаемся на встроенные анализаторы SDK. Стоимость при ошибке: вернуть пакет позже одной строкой.
- **Ruling 2** — перенос фронтенда (T1) разрушителен (`rm -rf frontend`): сначала полная проверка `src/frontend` (ключевые файлы + count), только потом удаление. Стоимость при ошибке: потеря node_modules (переустанавливаемо), исходники Vue восстанавливаются из src/frontend. - **Ruling 2** — перенос фронтенда (T1) разрушителен (`rm -rf frontend`): сначала полная проверка `src/frontend` (ключевые файлы + count), только потом удаление. Стоимость при ошибке: потеря node_modules (переустанавливаемо), исходники Vue восстанавливаются из src/frontend.
- **Ruling 3** — тесты T7 (TenantEntityTests) и T8 (TenantSchemaMigratorTests) импортируют `Deal.Infrastructure.*`, но T4 подключает к тестам только модули + SharedKernel. Решение: при T7 добавить `dotnet add tests/Deal.Tests.Unit reference Deal.Infrastructure`. Стоимость при ошибке: нет. - **Ruling 3** — тесты T7 (TenantEntityTests) и T8 (TenantSchemaMigratorTests) импортируют `Deal.Infrastructure.*`, но T4 подключает к тестам только модули + SharedKernel. Решение: при T7 добавить `dotnet add tests/Deal.Tests.Unit reference Deal.Infrastructure`. Стоимость при ошибке: нет.
- **Ruling 4** — ConnectionStringProvider принимает IConfiguration; у classlib Infrastructure нет ссылки на конфигурацию. Решение: в T6 добавить пакет `Microsoft.Extensions.Configuration.Abstractions` в Deal.Infrastructure. Стоимость при ошибке: нет. - **Ruling 4** — ConnectionStringProvider принимает IConfiguration; у classlib Infrastructure нет ссылки на конфигурацию. Решение: в T6 добавить пакет `Microsoft.Extensions.Configuration.Abstractions` в Deal.Infrastructure. Стоимость при ошибке: нет.
- **Ruling 5** — план требует `dotnet tool install --global dotnet-ef` (изменение вне дерева). Решение: проверить глобальный список; если нет — локальный tool-manifest в `src/core/.config` (`dotnet new tool-manifest` + `dotnet tool install dotnet-ef`). Стоимость при ошибке: лишний файл манифеста. - **Ruling 5** — план требует `dotnet tool install --global dotnet-ef` (изменение вне дерева). Решение: проверить глобальный список; если нет — локальный tool-manifest в `src/core/.config` (`dotnet new tool-manifest` + `dotnet tool install dotnet-ef`). Стоимость при ошибке: лишний файл манифеста.
## Task status ## Task status
- Task 1: complete (review clean). Отчёт: task-1-report.md. Minor deferred: файл `src/frontend/nul` (резервированное имя Windows, переехал из LeadRadar) — удалить, если нативные инструменты начнут спотыкаться. - Task 1: complete (review clean). Отчёт: task-1-report.md. Minor deferred: файл `src/frontend/nul` (резервированное имя Windows, переехал из LeadRadar) — удалить, если нативные инструменты начнут спотыкаться.
- Task 2: complete (review clean). Отчёт: task-2-report.md. - Task 2: complete (review clean). Отчёт: task-2-report.md.
- Task 3: complete (review clean). Отчёт: task-3-report.md. - Task 3: complete (review clean). Отчёт: task-3-report.md.
- Task 4: complete (review clean). Отчёт: task-4-report.md. - Task 4: complete (review clean). Отчёт: task-4-report.md.
- Task 5: complete (review clean). Отчёт: task-5-report.md. - Task 5: complete (review clean). Отчёт: task-5-report.md.
- Task 6: complete (review clean; единственное отклонение — явный `public` на членах ITenantContext из-за IDE0040:error, семантика не изменена). Отчёт: task-6-report.md. Note для следующих задач: сниппеты интерфейсов из плана могут требовать явных модификаторов доступа. - Task 6: complete (review clean; единственное отклонение — явный `public` на членах ITenantContext из-за IDE0040:error, семантика не изменена). Отчёт: task-6-report.md. Note для следующих задач: сниппеты интерфейсов из плана могут требовать явных модификаторов доступа.
- Task 7: complete (review clean; 3 отклонения — все задокументированы и одобрены ревью: Relational-пин в Infrastructure против MSB3277; Design PrivateAssets=all в Api для dotnet-ef; тест TenantEntity переписан на реальные дефолты — план сам разрешил «скорректировать»). Отчёт: task-7-report.md. - Task 7: complete (review clean; 3 отклонения — все задокументированы и одобрены ревью: Relational-пин в Infrastructure против MSB3277; Design PrivateAssets=all в Api для dotnet-ef; тест TenantEntity переписан на реальные дефолты — план сам разрешил «скорректировать»). Отчёт: task-7-report.md.
- Task 8: complete (review clean; производственный код verbatim; тест EscapesQuotes заменён на осмысленный — исходное утверждение плана падало на верном коде и проходило на баге). Отчёт: task-8-report.md. - Task 8: complete (review clean; производственный код verbatim; тест EscapesQuotes заменён на осмысленный — исходное утверждение плана падало на верном коде и проходило на баге). Отчёт: task-8-report.md.
- Task 9: complete (review clean). Отчёт: task-9-report.md. - Task 9: complete (review clean). Отчёт: task-9-report.md.
- **Этап 0 завершён**: все 9 задач complete; финальное whole-scope ревью ✅ (0 warnings/0 errors, 6/6 PASS, deal-postgres healthy, /api/health ок). Deferred minors: `src/frontend/nul` (не блокирует; решить в этапе 1), остальные — заметки процесса. - **Этап 0 завершён**: все 9 задач complete; финальное whole-scope ревью ✅ (0 warnings/0 errors, 6/6 PASS, deal-postgres healthy, /api/health ок). Deferred minors: `src/frontend/nul` (не блокирует; решить в этапе 1), остальные — заметки процесса.
+68 -68
View File
@@ -1,68 +1,68 @@
# Task 1 — «Структура src/ и перенос фронтенда» — отчёт # Task 1 — «Структура src/ и перенос фронтенда» — отчёт
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 1, строки 2788). Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 1, строки 2788).
## Что сделано ## Что сделано
**Step 1. Структура каталогов** — создано: **Step 1. Структура каталогов** — создано:
``` ```
src/core src/ml-service src/ai-service src/telegram-service src/contracts src/core src/ml-service src/ai-service src/telegram-service src/contracts
``` ```
(5 пустых каталогов, плюс `src/frontend` создан на шаге 2 — итого 6.) (5 пустых каталогов, плюс `src/frontend` создан на шаге 2 — итого 6.)
**Step 2. Перенос фронтенда**`frontend/``src/frontend/`. **Step 2. Перенос фронтенда**`frontend/``src/frontend/`.
- Скопировано содержимое старого `frontend/` включая скрытые файлы (`.gitignore`): - Скопировано содержимое старого `frontend/` включая скрытые файлы (`.gitignore`):
`mkdir -p src/frontend && cp -r frontend/. src/frontend/`. `mkdir -p src/frontend && cp -r frontend/. src/frontend/`.
- Затем (после верификации копии) старый `frontend/` удалён: `rm -rf frontend`. - Затем (после верификации копии) старый `frontend/` удалён: `rm -rf frontend`.
**Step 3.** Создан `src/README.md` — содержимое 1-в-1 из плана (строки 55–66). **Step 3.** Создан `src/README.md` — содержимое 1-в-1 из плана (строки 55–66).
**Step 4.** Создан корневой `README.md` — содержимое 1-в-1 из плана (строки 71–79). **Step 4.** Создан корневой `README.md` — содержимое 1-в-1 из плана (строки 71–79).
**Не создано ничего сверх списка**: ни `Directory.Build.props`, ни csproj, ни иных файлов. **Не создано ничего сверх списка**: ни `Directory.Build.props`, ни csproj, ни иных файлов.
LeadRadar-код (`backend/`, `mlservice/`, `docker-compose.yml`, `docs/`) не тронут. LeadRadar-код (`backend/`, `mlservice/`, `docker-compose.yml`, `docs/`) не тронут.
## Проверки (выводы команд) ## Проверки (выводы команд)
- Счёт файлов старого `frontend` ДО переноса: `find frontend -type f | wc -l`**1085**. - Счёт файлов старого `frontend` ДО переноса: `find frontend -type f | wc -l`**1085**.
- Счёт файлов `src/frontend` ПОСЛЕ переноса (до удаления старого): → **1085** — равно. - Счёт файлов `src/frontend` ПОСЛЕ переноса (до удаления старого): → **1085** — равно.
- Полное сравнение списков файлов (относительные пути, отсортированы): - Полное сравнение списков файлов (относительные пути, отсортированы):
`diff` списков `frontend/` и `src/frontend/`**IDENTICAL**. `diff` списков `frontend/` и `src/frontend/`**IDENTICAL**.
- `ls src/` → 6 папок: `ai-service contracts core frontend ml-service telegram-service` (+ `README.md`). - `ls src/` → 6 папок: `ai-service contracts core frontend ml-service telegram-service` (+ `README.md`).
- `ls src/frontend/``package.json`, `index.html`, `src/`, `vite.config.js`, `dist/`, - `ls src/frontend/``package.json`, `index.html`, `src/`, `vite.config.js`, `dist/`,
`node_modules/`, `package-lock.json`, `.gitignore`, `nul`, логи vite. `node_modules/`, `package-lock.json`, `.gitignore`, `nul`, логи vite.
- `test -f README.md && echo ok``ok`; `test -f src/README.md``ok`. - `test -f README.md && echo ok``ok`; `test -f src/README.md``ok`.
- После удаления: `find src/frontend -type f | wc -l`**1085**; `frontend/` отсутствует. - После удаления: `find src/frontend -type f | wc -l`**1085**; `frontend/` отсутствует.
## Отклонение от буквы плана (обоснованное) ## Отклонение от буквы плана (обоснованное)
1. План: `cp -r frontend/* src/frontend/`. Использовано `cp -r frontend/. src/frontend/`: 1. План: `cp -r frontend/* src/frontend/`. Использовано `cp -r frontend/. src/frontend/`:
glob `*` не захватывает dot-файлы (`.gitignore`), что нарушило бы требование равенства glob `*` не захватывает dot-файлы (`.gitignore`), что нарушило бы требование равенства
счётчиков файлов (guard) — 1085 до vs 1084 после. `frontend/.` копирует и скрытые файлы, счётчиков файлов (guard) — 1085 до vs 1084 после. `frontend/.` копирует и скрытые файлы,
результат проверен: списки файлов идентичны. результат проверен: списки файлов идентичны.
2. План: `cp -r ... && rm -rf frontend` одной командой. Деструктивный шаг выполнен отдельно, 2. План: `cp -r ... && rm -rf frontend` одной командой. Деструктивный шаг выполнен отдельно,
только после верификации копии, как требует guard. только после верификации копии, как требует guard.
## Инцидент: запущенный vite dev server в старом frontend/ ## Инцидент: запущенный vite dev server в старом frontend/
`rm -rf frontend` частично не удался: файлы `frontend/vite-dev.log`, `frontend/vite-dev.err.log` `rm -rf frontend` частично не удался: файлы `frontend/vite-dev.log`, `frontend/vite-dev.err.log`
и пересозданный кэш `frontend/.vite/deps/*` были заблокированы (Device or resource busy). и пересозданный кэш `frontend/.vite/deps/*` были заблокированы (Device or resource busy).
Причина установлена: с 2026-09-02 работал живой процесс vite dev server Причина установлена: с 2026-09-02 работал живой процесс vite dev server
(`node C:\telbase\frontend\node_modules\.bin\..\vite\bin\vite.js`, Windows PID 395892, родитель cmd 392284), (`node C:\telbase\frontend\node_modules\.bin\..\vite\bin\vite.js`, Windows PID 395892, родитель cmd 392284),
державший дескрипторы на логи в старом каталоге. Его `node_modules/`, `src/`, `dist/` уже были удалены державший дескрипторы на логи в старом каталоге. Его `node_modules/`, `src/`, `dist/` уже были удалены
шагом 2, т.е. сервер был неработоспособен независимо от наших действий. шагом 2, т.е. сервер был неработоспособен независимо от наших действий.
Выполнено: `taskkill //PID 395892 //T //F` (только дерево этого vite-процесса), после чего Выполнено: `taskkill //PID 395892 //T //F` (только дерево этого vite-процесса), после чего
`rm -rf frontend` успешен. Остальные node-процессы не трогались. `rm -rf frontend` успешен. Остальные node-процессы не трогались.
## Опасения / Concerns ## Опасения / Concerns
- Был принудительно завершён зависший vite dev server старого `frontend/` (см. инцидент). - Был принудительно завершён зависший vite dev server старого `frontend/` (см. инцидент).
При необходимости dev-сервер нужно запускать заново из `src/frontend/`. При необходимости dev-сервер нужно запускать заново из `src/frontend/`.
- В `src/frontend/` переехал файл с именем `nul` (215 байт, существовал в исходном `frontend/` - В `src/frontend/` переехал файл с именем `nul` (215 байт, существовал в исходном `frontend/`
до переноса) — перенос выполнен как есть, файл сохранён. до переноса) — перенос выполнен как есть, файл сохранён.
- Служебные логи `vite-dev.log`/`vite-dev.err.log` и каталог `dist/`, `node_modules/` перенесены - Служебные логи `vite-dev.log`/`vite-dev.err.log` и каталог `dist/`, `node_modules/` перенесены
как есть (план: «фронтенд переезжает как есть»). как есть (план: «фронтенд переезжает как есть»).
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом. - Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
## Статус задачи ## Статус задачи
DONE_WITH_CONCERNS (все шаги 1–6 выполнены и проверены; concern — завершённый vite-процесс). DONE_WITH_CONCERNS (все шаги 1–6 выполнены и проверены; concern — завершённый vite-процесс).
+50 -50
View File
@@ -1,50 +1,50 @@
# Task 2 — «Стандарты кода — .editorconfig, Directory.Build.props» — отчёт # Task 2 — «Стандарты кода — .editorconfig, Directory.Build.props» — отчёт
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 2, строки 90167). Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 2, строки 90167).
## Что сделано ## Что сделано
**Step 1.** Создан корневой `.editorconfig` (`C:\telbase\.editorconfig`) — содержимое 1-в-1 **Step 1.** Создан корневой `.editorconfig` (`C:\telbase\.editorconfig`) — содержимое 1-в-1
из плана (строки 102–139 внутри fence, 38 строк): `root = true`, базовые настройки (`[*]`), из плана (строки 102–139 внутри fence, 38 строк): `root = true`, базовые настройки (`[*]`),
`[*.{cs,vb}]`, секция стиля фигурных скобок (Allman), using-директивы, явные модификаторы `[*.{cs,vb}]`, секция стиля фигурных скобок (Allman), using-директивы, явные модификаторы
доступа, отключение квалификации `this.`, правила `var`, секция `[*.cs]` с доступа, отключение квалификации `this.`, правила `var`, секция `[*.cs]` с
`dotnet_diagnostic.IDE0290.severity = none`. `dotnet_diagnostic.IDE0290.severity = none`.
**Step 2.** Создан `src/core/Directory.Build.props` — PropertyGroup из плана (строки 146–154) **Step 2.** Создан `src/core/Directory.Build.props` — PropertyGroup из плана (строки 146–154)
1-в-1: `TargetFramework net10.0`, `LangVersion latest`, `Nullable enable`, 1-в-1: `TargetFramework net10.0`, `LangVersion latest`, `Nullable enable`,
`ImplicitUsings enable`, `TreatWarningsAsErrors true`, `AnalysisLevel latest`, `ImplicitUsings enable`, `TreatWarningsAsErrors true`, `AnalysisLevel latest`,
`EnforceCodeStyleInBuild true`. `EnforceCodeStyleInBuild true`.
**Step 3.** Зафиксирован настоящий отчёт. **Step 3.** Зафиксирован настоящий отчёт.
**Не создано ничего сверх списка**: задача ограничена двумя файлами. `src/core/` пуст **Не создано ничего сверх списка**: задача ограничена двумя файлами. `src/core/` пуст
(кроме нового props), код LeadRadar не тронут. Сборка не выполнялась (запрошено задачей; (кроме нового props), код LeadRadar не тронут. Сборка не выполнялась (запрошено задачей;
проектов под `src/core` ещё нет — проверка в Task 3+). проектов под `src/core` ещё нет — проверка в Task 3+).
## Рeшение контроллера (применено) ## Рeшение контроллера (применено)
`<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" ...>` `<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" ...>`
(план, строки 156–161) **полностью опущен**: SDK .NET 10 (10.0.400) уже включает (план, строки 156–161) **полностью опущен**: SDK .NET 10 (10.0.400) уже включает
встроенные analyzers той же версии; явная ссылка не нужна. Остальные элементы props встроенные analyzers той же версии; явная ссылка не нужна. Остальные элементы props
совпадают с планом без изменений. совпадают с планом без изменений.
## Проверки ## Проверки
- Оба файла существуют: чтение `.editorconfig` (38 строк) и `Directory.Build.props` (11 строк). - Оба файла существуют: чтение `.editorconfig` (38 строк) и `Directory.Build.props` (11 строк).
- `.editorconfig` сверен построчно с планом (строки 102–139) — идентичен, включая пустые - `.editorconfig` сверен построчно с планом (строки 102–139) — идентичен, включая пустые
строки и русские комментарии. строки и русские комментарии.
- `Directory.Build.props` сверен с планом (строки 145–154) — идентичен; ItemGroup - `Directory.Build.props` сверен с планом (строки 145–154) — идентичен; ItemGroup
отсутствует; наличие `PackageReference` в файле не подтверждается (grep-проверка не нужна — отсутствует; наличие `PackageReference` в файле не подтверждается (grep-проверка не нужна —
содержимое прочитано целиком). содержимое прочитано целиком).
- `ls src/core/` → содержит только `Directory.Build.props`. - `ls src/core/` → содержит только `Directory.Build.props`.
## Опасения / Concerns ## Опасения / Concerns
- Нет. Правило `EnforceCodeStyleInBuild` + `TreatWarningsAsErrors` начнёт действовать после - Нет. Правило `EnforceCodeStyleInBuild` + `TreatWarningsAsErrors` начнёт действовать после
создания проектов (Task 3) — тогда же и проверка сборки. создания проектов (Task 3) — тогда же и проверка сборки.
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом. - Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
## Статус задачи ## Статус задачи
DONE (все шаги 1–3 выполнены и проверены; отклонений от плана нет, кроме предписанного DONE (все шаги 1–3 выполнены и проверены; отклонений от плана нет, кроме предписанного
контроллером исключения PackageReference). контроллером исключения PackageReference).
+75 -75
View File
@@ -1,75 +1,75 @@
# Task 3 — «Решение Deal.sln и пустые проекты core» — отчёт # Task 3 — «Решение Deal.sln и пустые проекты core» — отчёт
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 3, строки 169258). Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 3, строки 169258).
Рабочая директория: `C:\telbase\src\core`. Рабочая директория: `C:\telbase\src\core`.
## Что сделано ## Что сделано
**Step 1.** Созданы решение и проекты (`dotnet new`, шаблоны SDK .NET 10.0.400): **Step 1.** Созданы решение и проекты (`dotnet new`, шаблоны SDK .NET 10.0.400):
`Deal.sln`; `Deal.Api` (`web --no-https`); 6 модулей `Deal.Modules.{Pipeline,Kanban,Projects,Discovery,Settings,Tenants}`; `Deal.sln`; `Deal.Api` (`web --no-https`); 6 модулей `Deal.Modules.{Pipeline,Kanban,Projects,Discovery,Settings,Tenants}`;
`Deal.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (все — `classlib`). `Deal.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (все — `classlib`).
**Step 2.** Все 10 проектов добавлены в решение (`dotnet sln Deal.sln add ...` — 10/10 подтверждено выводом). **Step 2.** Все 10 проектов добавлены в решение (`dotnet sln Deal.sln add ...` — 10/10 подтверждено выводом).
**Step 3.** Удалён сгенерированный `Class1.cs` из всех 9 classlib-проектов. Созданы публичные **Step 3.** Удалён сгенерированный `Class1.cs` из всех 9 classlib-проектов. Созданы публичные
sealed-маркеры (1 тип = 1 файл, имя файла = имени типа, XML-doc, содержимое по плану): sealed-маркеры (1 тип = 1 файл, имя файла = имени типа, XML-doc, содержимое по плану):
`PipelineModuleMarker`, `KanbanModuleMarker`, `ProjectsModuleMarker`, `DiscoveryModuleMarker`, `PipelineModuleMarker`, `KanbanModuleMarker`, `ProjectsModuleMarker`, `DiscoveryModuleMarker`,
`SettingsModuleMarker`, `TenantsModuleMarker`, `InfrastructureMarker`, `SharedKernelMarker`, `SettingsModuleMarker`, `TenantsModuleMarker`, `InfrastructureMarker`, `SharedKernelMarker`,
`ContractsMarker`. `ContractsMarker`.
**Step 4.** Project references (минимальные, по плану): `Deal.Api` → SharedKernel/Contracts/Infrastructure; **Step 4.** Project references (минимальные, по плану): `Deal.Api` → SharedKernel/Contracts/Infrastructure;
каждый модуль и `Deal.Infrastructure` → SharedKernel/Contracts. `Deal.SharedKernel`/`Deal.Contracts` — без ссылок. каждый модуль и `Deal.Infrastructure` → SharedKernel/Contracts. `Deal.SharedKernel`/`Deal.Contracts` — без ссылок.
**Step 5.** `Deal.Api/Program.cs` заменён 1-в-1 на содержимое плана (строки 233245): health-endpoint **Step 5.** `Deal.Api/Program.cs` заменён 1-в-1 на содержимое плана (строки 233245): health-endpoint
`GET /api/health` + `public partial class Program`. `GET /api/health` + `public partial class Program`.
**Step 6.** `dotnet build Deal.sln` → успешно, 0 предупреждений, 0 ошибок (строгий режим **Step 6.** `dotnet build Deal.sln` → успешно, 0 предупреждений, 0 ошибок (строгий режим
TreatWarningsAsErrors + EnforceCodeStyleInBuild соблюдён). TreatWarningsAsErrors + EnforceCodeStyleInBuild соблюдён).
**Step 7.** Health-проверка: приложение поднято в фоне на `http://localhost:5080`, `curl` **Step 7.** Health-проверка: приложение поднято в фоне на `http://localhost:5080`, `curl`
вернул ожидаемый JSON, процесс остановлен, порт освобождён (netstat: слушателей нет). вернул ожидаемый JSON, процесс остановлен, порт освобождён (netstat: слушателей нет).
**Step 8.** Зафиксирован настоящий отчёт. **Step 8.** Зафиксирован настоящий отчёт.
## Созданные файлы ## Созданные файлы
- `src/core/Deal.sln` - `src/core/Deal.sln`
- `src/core/Deal.Api/Deal.Api.csproj`, `Deal.Api/Program.cs`, `Deal.Api/Properties/launchSettings.json` (шаблон) - `src/core/Deal.Api/Deal.Api.csproj`, `Deal.Api/Program.cs`, `Deal.Api/Properties/launchSettings.json` (шаблон)
- `src/core/Deal.Api/Program.cs` — health-версия по плану - `src/core/Deal.Api/Program.cs` — health-версия по плану
- 9 classlib-проектов: `Deal.Modules.Pipeline/…Tenants`, `Deal.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (csproj + маркер) - 9 classlib-проектов: `Deal.Modules.Pipeline/…Tenants`, `Deal.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (csproj + маркер)
- Маркеры: `PipelineModuleMarker.cs`, `KanbanModuleMarker.cs`, `ProjectsModuleMarker.cs`, `DiscoveryModuleMarker.cs`, - Маркеры: `PipelineModuleMarker.cs`, `KanbanModuleMarker.cs`, `ProjectsModuleMarker.cs`, `DiscoveryModuleMarker.cs`,
`SettingsModuleMarker.cs`, `TenantsModuleMarker.cs`, `InfrastructureMarker.cs`, `SharedKernelMarker.cs`, `ContractsMarker.cs` `SettingsModuleMarker.cs`, `TenantsModuleMarker.cs`, `InfrastructureMarker.cs`, `SharedKernelMarker.cs`, `ContractsMarker.cs`
- `ProjectReference` во всех csproj по плану (см. Step 4) - `ProjectReference` во всех csproj по плану (см. Step 4)
- `.superpowers/sdd/deal-scaffold/task-3-report.md` - `.superpowers/sdd/deal-scaffold/task-3-report.md`
## Проверки ## Проверки
**Сборка** (`dotnet build Deal.sln`, Debug): 10/10 проектов `успешно`, «Сборка успешно выполнено через 7,4 с»; **Сборка** (`dotnet build Deal.sln`, Debug): 10/10 проектов `успешно`, «Сборка успешно выполнено через 7,4 с»;
вывод без предупреждений и ошибок (при TreatWarningsAsErrors любое предупреждение уронило бы сборку). вывод без предупреждений и ошибок (при TreatWarningsAsErrors любое предупреждение уронило бы сборку).
**Health** (Step 7): **Health** (Step 7):
``` ```
=== GET http://localhost:5080/api/health === === GET http://localhost:5080/api/health ===
{"ok":true,"service":"deal"} ← curl exit 0, ожидаемый ответ {"ok":true,"service":"deal"} ← curl exit 0, ожидаемый ответ
=== app log === === app log ===
Now listening on: http://localhost:5080 Now listening on: http://localhost:5080
Hosting environment: Development Hosting environment: Development
=== stopping app: kill <pid> === === stopping app: kill <pid> ===
=== no leftover listener on 5080 === ← процесс остановлен, порт свободен === no leftover listener on 5080 === ← процесс остановлен, порт свободен
``` ```
## Опасения / Concerns ## Опасения / Concerns
- `.NET 10 default slnx`: `dotnet new sln` в SDK 10.0.400 создаёт `Deal.slnx`, а не `Deal.sln`. - `.NET 10 default slnx`: `dotnet new sln` в SDK 10.0.400 создаёт `Deal.slnx`, а не `Deal.sln`.
План требует именно `Deal.sln` (имя используется в командах `sln add`/`build`). Решение: План требует именно `Deal.sln` (имя используется в командах `sln add`/`build`). Решение:
`Deal.slnx` удалён, решение пересоздано как классическое: `dotnet new sln -n Deal --format sln`. `Deal.slnx` удалён, решение пересоздано как классическое: `dotnet new sln -n Deal --format sln`.
Итог — `Deal.sln` (классический формат), артефактов slnx не осталось. Итог — `Deal.sln` (классический формат), артефактов slnx не осталось.
- Health-скрипт: в финальной строке проверки «после остановки» код возврата `curl` выведен неверно - Health-скрипт: в финальной строке проверки «после остановки» код возврата `curl` выведен неверно
(`$?` после `echo`), но факт остановки подтверждён авторитетно: тело ответа пустое (сервер не отвечает) (`$?` после `echo`), но факт остановки подтверждён авторитетно: тело ответа пустое (сервер не отвечает)
и `netstat` не показывает слушателей на порту 5080. и `netstat` не показывает слушателей на порту 5080.
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом. - Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
## Статус задачи ## Статус задачи
DONE (все шаги 1–8 выполнены; сборка 0/0; health-ответ совпал с ожидаемым; процесс остановлен). DONE (все шаги 1–8 выполнены; сборка 0/0; health-ответ совпал с ожидаемым; процесс остановлен).
+67 -67
View File
@@ -1,67 +1,67 @@
# Task 4 — «Тесты — xUnit-каркас» — отчёт # Task 4 — «Тесты — xUnit-каркас» — отчёт
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 4, строки 261306). Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 4, строки 261306).
Рабочая директория: `C:\telbase\src\core`. Рабочая директория: `C:\telbase\src\core`.
## Что сделано ## Что сделано
**Step 1.** Создан тестовый проект: **Step 1.** Создан тестовый проект:
``` ```
dotnet new xunit -n Deal.Tests.Unit -o tests/Deal.Tests.Unit dotnet new xunit -n Deal.Tests.Unit -o tests/Deal.Tests.Unit
dotnet sln Deal.sln add tests/Deal.Tests.Unit dotnet sln Deal.sln add tests/Deal.Tests.Unit
dotnet add tests/Deal.Tests.Unit reference Deal.SharedKernel Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants dotnet add tests/Deal.Tests.Unit reference Deal.SharedKernel Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants
``` ```
- Шаблон .NET 10 SDK сгенерировал классический набор xunit v2.9.3 + `Microsoft.NET.Test.Sdk` 17.14.1 + - Шаблон .NET 10 SDK сгенерировал классический набор xunit v2.9.3 + `Microsoft.NET.Test.Sdk` 17.14.1 +
`xunit.runner.visualstudio` 3.1.4 + `coverlet.collector` 6.0.4 (VSTest, НЕ xunit v3 / Microsoft.Testing.Platform), `xunit.runner.visualstudio` 3.1.4 + `coverlet.collector` 6.0.4 (VSTest, НЕ xunit v3 / Microsoft.Testing.Platform),
поэтому `dotnet test` работает штатно, адаптация не потребовалась. поэтому `dotnet test` работает штатно, адаптация не потребовалась.
- Проект добавлен в `Deal.sln` (11-й проект). - Проект добавлен в `Deal.sln` (11-й проект).
- Добавлены 7 ProjectReference строго по плану: `Deal.SharedKernel` + 6 модулей - Добавлены 7 ProjectReference строго по плану: `Deal.SharedKernel` + 6 модулей
(`Pipeline`, `Kanban`, `Projects`, `Discovery`, `Settings`, `Tenants`). `Deal.Infrastructure` НЕ добавлен (`Pipeline`, `Kanban`, `Projects`, `Discovery`, `Settings`, `Tenants`). `Deal.Infrastructure` НЕ добавлен
(добавляется более поздней задачей). (добавляется более поздней задачей).
**Step 2.** Удалён шаблонный `UnitTest1.cs`. Создан единственный тестовый файл **Step 2.** Удалён шаблонный `UnitTest1.cs`. Создан единственный тестовый файл
`tests/Deal.Tests.Unit/MarkerTests.cs` — содержимое 1-в-1 из плана (строки 284–298): `tests/Deal.Tests.Unit/MarkerTests.cs` — содержимое 1-в-1 из плана (строки 284–298):
`MarkerTests` (public sealed) с единственным `[Fact] PipelineModuleMarker_IsPublicAndSealed`, `MarkerTests` (public sealed) с единственным `[Fact] PipelineModuleMarker_IsPublicAndSealed`,
проверяющим `IsPublic`/`IsSealed` у `Deal.Modules.Pipeline.PipelineModuleMarker`. проверяющим `IsPublic`/`IsSealed` у `Deal.Modules.Pipeline.PipelineModuleMarker`.
**Step 3.** `dotnet test tests/Deal.Tests.Unit` → ожидаемый результат: ровно 1 тест PASS. **Step 3.** `dotnet test tests/Deal.Tests.Unit` → ожидаемый результат: ровно 1 тест PASS.
## Вывод тестового запуска (финал) ## Вывод тестового запуска (финал)
``` ```
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.11) [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.11)
[xUnit.net 00:00:00.08] Discovering: Deal.Tests.Unit [xUnit.net 00:00:00.08] Discovering: Deal.Tests.Unit
[xUnit.net 00:00:00.12] Discovered: Deal.Tests.Unit [xUnit.net 00:00:00.12] Discovered: Deal.Tests.Unit
[xUnit.net 00:00:00.14] Starting: Deal.Tests.Unit [xUnit.net 00:00:00.14] Starting: Deal.Tests.Unit
[xUnit.net 00:00:00.18] Finished: Deal.Tests.Unit [xUnit.net 00:00:00.18] Finished: Deal.Tests.Unit
Сводка теста: всего: 1; сбой: 0; успешно: 1; пропущено: 0; длительность: 1,4 с Сводка теста: всего: 1; сбой: 0; успешно: 1; пропущено: 0; длительность: 1,4 с
Сборка успешно выполнено через 4,5 с Сборка успешно выполнено через 4,5 с
``` ```
## Проверки ## Проверки
- `dotnet test tests/Deal.Tests.Unit`: 1 тест найден → 1 PASS, 0 fail, 0 skipped. - `dotnet test tests/Deal.Tests.Unit`: 1 тест найден → 1 PASS, 0 fail, 0 skipped.
- Сборка 0 предупреждений / 0 ошибок при TreatWarningsAsErrors=true + EnforceCodeStyleInBuild=true — - Сборка 0 предупреждений / 0 ошибок при TreatWarningsAsErrors=true + EnforceCodeStyleInBuild=true —
иначе запуск упал бы на любом предупреждении. `Directory.Build.props`/`.editorconfig` не менялись. иначе запуск упал бы на любом предупреждении. `Directory.Build.props`/`.editorconfig` не менялись.
- В транзитивную сборку попали все 7 referenced-проектов + `Deal.Contracts` (через SharedKernel/модули) — успешно. - В транзитивную сборку попали все 7 referenced-проектов + `Deal.Contracts` (через SharedKernel/модули) — успешно.
## Созданные файлы ## Созданные файлы
- `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` (шаблон, без правок) - `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` (шаблон, без правок)
- `src/core/tests/Deal.Tests.Unit/MarkerTests.cs` (по плану, verbatim) - `src/core/tests/Deal.Tests.Unit/MarkerTests.cs` (по плану, verbatim)
- `tests/Deal.Tests.Unit` добавлен в `src/core/Deal.sln` - `tests/Deal.Tests.Unit` добавлен в `src/core/Deal.sln`
- `.superpowers/sdd/deal-scaffold/task-4-report.md` - `.superpowers/sdd/deal-scaffold/task-4-report.md`
## Опасения / Concerns ## Опасения / Concerns
- Шаблонный `Deal.Tests.Unit.csproj` дублирует `Nullable`/`ImplicitUsings`/`TargetFramework`, уже заданные в - Шаблонный `Deal.Tests.Unit.csproj` дублирует `Nullable`/`ImplicitUsings`/`TargetFramework`, уже заданные в
`src/core/Directory.Build.props` (net10.0). Это безопасно и не вызвало ошибок сборки. `src/core/Directory.Build.props` (net10.0). Это безопасно и не вызвало ошибок сборки.
- `xunit.runner.visualstudio` 3.1.4 в связке с `xunit` 2.9.3 — штатная комбинация шаблона .NET 10 SDK, - `xunit.runner.visualstudio` 3.1.4 в связке с `xunit` 2.9.3 — штатная комбинация шаблона .NET 10 SDK,
тесты обнаружены и выполнены корректно. тесты обнаружены и выполнены корректно.
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом. - Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
## Статус задачи ## Статус задачи
DONE (Steps 1–4 плана выполнены; `dotnet test` → ровно 1 PASS; 0 предупреждений / 0 ошибок). DONE (Steps 1–4 плана выполнены; `dotnet test` → ровно 1 PASS; 0 предупреждений / 0 ошибок).
+53 -53
View File
@@ -1,53 +1,53 @@
# Task 5 Report: Dev-Postgres в docker compose (схема на тенанта) # Task 5 Report: Dev-Postgres в docker compose (схема на тенанта)
Date: 2026-09-05 Date: 2026-09-05
## Files created ## Files created
- `deploy/compose.dev.yml` — verbatim from the plan (Step 1): service `postgres`, image `postgres:16-alpine`, `container_name: deal-postgres`, host port `5433:5432`, volume `deal_pgdata`, healthcheck `pg_isready -U deal -d deal` (interval 5s, timeout 3s, retries 10). - `deploy/compose.dev.yml` — verbatim from the plan (Step 1): service `postgres`, image `postgres:16-alpine`, `container_name: deal-postgres`, host port `5433:5432`, volume `deal_pgdata`, healthcheck `pg_isready -U deal -d deal` (interval 5s, timeout 3s, retries 10).
- `deploy/.env.example` — verbatim from the plan (Step 2): `DEAL_PG_HOST/PORT/DB/USER/PASSWORD` (`localhost:5433`, `deal`/`deal`/`deal_dev_password`). - `deploy/.env.example` — verbatim from the plan (Step 2): `DEAL_PG_HOST/PORT/DB/USER/PASSWORD` (`localhost:5433`, `deal`/`deal`/`deal_dev_password`).
`deploy/` directory did not exist and was created. `deploy/` directory did not exist and was created.
## Container status ## Container status
- Command: `docker compose -f deploy/compose.dev.yml up -d` - Command: `docker compose -f deploy/compose.dev.yml up -d`
- Result: `deal-postgres` created and started; image was already present locally (no long pull). - Result: `deal-postgres` created and started; image was already present locally (no long pull).
- Final status: `Up (healthy)` on `0.0.0.0:5433->5432/tcp`. - Final status: `Up (healthy)` on `0.0.0.0:5433->5432/tcp`.
- Existing LeadRadar containers (`leadradar`, `leadradar-ml`, `leadradar-minio`) were NOT touched and remain running. - Existing LeadRadar containers (`leadradar`, `leadradar-ml`, `leadradar-minio`) were NOT touched and remain running.
## psql verification (Step 4) ## psql verification (Step 4)
Command: Command:
``` ```
docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();" docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();"
``` ```
Output: Output:
``` ```
current_database | current_schema current_database | current_schema
------------------+---------------- ------------------+----------------
deal | public deal | public
(1 row) (1 row)
``` ```
Expected `deal | public` — PASS. DB `deal`, default schema `public` ready for migrations. Expected `deal | public` — PASS. DB `deal`, default schema `public` ready for migrations.
## README change (Step 5) ## README change (Step 5)
Appended to root `README.md` (existing content intact): Appended to root `README.md` (existing content intact):
```markdown ```markdown
## Запуск dev-окружения ## Запуск dev-окружения
Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d` Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d`
``` ```
## Concerns ## Concerns
- None blocking. Minor notes: - None blocking. Minor notes:
- Password `deal_dev_password` is hardcoded dev-only value, fine for local dev per plan. - Password `deal_dev_password` is hardcoded dev-only value, fine for local dev per plan.
- Compose project name defaults to `deploy` (network `deploy_default`, volume `deploy_deal_pgdata`), since no `name:` was set in the plan file. Expected per plan; consistent on any host using `-f deploy/compose.dev.yml`. - Compose project name defaults to `deploy` (network `deploy_default`, volume `deploy_deal_pgdata`), since no `name:` was set in the plan file. Expected per plan; consistent on any host using `-f deploy/compose.dev.yml`.
- `postgres:16-alpine` was already cached locally, so startup was fast; first pull on a fresh host will take longer. - `postgres:16-alpine` was already cached locally, so startup was fast; first pull on a fresh host will take longer.
+54 -54
View File
@@ -1,54 +1,54 @@
# Task 6 — Tenant-контекст и подключение к Postgres (отчёт) # Task 6 — Tenant-контекст и подключение к Postgres (отчёт)
**Дата:** 2026-09-05 **Дата:** 2026-09-05
**Статус:** ✅ Выполнено (build 0 warnings / 0 errors, 3 теста PASS) **Статус:** ✅ Выполнено (build 0 warnings / 0 errors, 3 теста PASS)
## Файлы ## Файлы
### Созданы (по коду плана, verbatim) ### Созданы (по коду плана, verbatim)
- `src/core/Deal.SharedKernel/Tenants/TenantId.cs``readonly record struct TenantId`, валидация непустого `Value`, `SchemaName => "tenant_{Value}"`. - `src/core/Deal.SharedKernel/Tenants/TenantId.cs``readonly record struct TenantId`, валидация непустого `Value`, `SchemaName => "tenant_{Value}"`.
- `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs``TenantId? TenantId`, `HasTenant`, `string? SchemaName`. - `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs``TenantId? TenantId`, `HasTenant`, `string? SchemaName`.
- `src/core/Deal.Infrastructure/Data/TenantContext.cs``AsyncLocal<TenantId?>`, имплементирует `ITenantContext` + `SetTenant`. - `src/core/Deal.Infrastructure/Data/TenantContext.cs``AsyncLocal<TenantId?>`, имплементирует `ITenantContext` + `SetTenant`.
- `src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs` — ctor бросает `InvalidOperationException` без `ConnectionStrings:DealPostgres`; `ForTenant` добавляет `;Search Path=...`. - `src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs` — ctor бросает `InvalidOperationException` без `ConnectionStrings:DealPostgres`; `ForTenant` добавляет `;Search Path=...`.
- `tests/Deal.Tests.Unit/TenantIdTests.cs` — 2 теста: `SchemaName_PrefixesTenant`, `TenantId_Empty_Throws`. - `tests/Deal.Tests.Unit/TenantIdTests.cs` — 2 теста: `SchemaName_PrefixesTenant`, `TenantId_Empty_Throws`.
### Изменены ### Изменены
- `src/core/Deal.Api/Program.cs` — добавлены `using Deal.Infrastructure.Data;` и `using Deal.SharedKernel.Tenants;`, зарегистрированы `builder.Services.AddSingleton<ITenantContext, TenantContext>();` и `AddSingleton<ConnectionStringProvider>();`. `/api/health` и `public partial class Program` не тронуты. - `src/core/Deal.Api/Program.cs` — добавлены `using Deal.Infrastructure.Data;` и `using Deal.SharedKernel.Tenants;`, зарегистрированы `builder.Services.AddSingleton<ITenantContext, TenantContext>();` и `AddSingleton<ConnectionStringProvider>();`. `/api/health` и `public partial class Program` не тронуты.
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен `PackageReference Microsoft.Extensions.Configuration.Abstractions 10.0.11` (командой `dotnet add`, Ruling 4). - `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен `PackageReference Microsoft.Extensions.Configuration.Abstractions 10.0.11` (командой `dotnet add`, Ruling 4).
## Отклонение от кода плана (требование стиля репозитория) ## Отклонение от кода плана (требование стиля репозитория)
`ITenantContext.cs`: члены интерфейса получили явный `public` (план их опускал). Иначе сборка падает с `IDE0040` (error): в корневом `C:\telbase\.editorconfig` задано `dotnet_style_require_accessibility_modifiers = always:error`, а `EnforceCodeStyleInBuild=true` + `TreatWarningsAsErrors=true`. Изменение семантики не меняет — члены интерфейса и так public. `.editorconfig`/`Directory.Build.props` не редактировались. `ITenantContext.cs`: члены интерфейса получили явный `public` (план их опускал). Иначе сборка падает с `IDE0040` (error): в корневом `C:\telbase\.editorconfig` задано `dotnet_style_require_accessibility_modifiers = always:error`, а `EnforceCodeStyleInBuild=true` + `TreatWarningsAsErrors=true`. Изменение семантики не меняет — члены интерфейса и так public. `.editorconfig`/`Directory.Build.props` не редактировались.
`TenantId` (record struct с property-initializer-валидацией) успешно компилируется под net10.0 — перепроектирование не потребовалось. `TenantId` (record struct с property-initializer-валидацией) успешно компилируется под net10.0 — перепроектирование не потребовалось.
## Build ## Build
Команда: `dotnet build Deal.sln` (из `src/core`) Команда: `dotnet build Deal.sln` (из `src/core`)
``` ```
Восстановление завершено (0,8 с) Восстановление завершено (0,8 с)
Deal.SharedKernel net10.0 успешно выполнено Deal.SharedKernel net10.0 успешно выполнено
... ...
Deal.Api net10.0 успешно выполнено (0,6 с) Deal.Api net10.0 успешно выполнено (0,6 с)
Сборка успешно выполнено через 2,5 с Сборка успешно выполнено через 2,5 с
``` ```
Результат: 0 warnings, 0 errors (11 проектов собраны). Результат: 0 warnings, 0 errors (11 проектов собраны).
## Tests ## Tests
Команда: `dotnet test tests/Deal.Tests.Unit --no-build` Команда: `dotnet test tests/Deal.Tests.Unit --no-build`
``` ```
Сводка теста: всего: 3; сбой: 0; успешно: 3; пропущено: 0; длительность: 1,1 с Сводка теста: всего: 3; сбой: 0; успешно: 3; пропущено: 0; длительность: 1,1 с
``` ```
PASS: `MarkerTests` (1) + `TenantIdTests` (2) = ровно 3. PASS: `MarkerTests` (1) + `TenantIdTests` (2) = ровно 3.
## Concerns / заметки ## Concerns / заметки
1. **Template-level conflict (план vs .editorconfig):** код `ITenantContext` в плане не проходит `IDE0040` — потребовался явный `public` на членах интерфейса. Аналогичное стоит ожидать в будущих задачах, где план опускает модификаторы доступа у членов интерфейса. 1. **Template-level conflict (план vs .editorconfig):** код `ITenantContext` в плане не проходит `IDE0040` — потребовался явный `public` на членах интерфейса. Аналогичное стоит ожидать в будущих задачах, где план опускает модификаторы доступа у членов интерфейса.
2. `ConnectionStringProvider` зарегистрирован в DI, но нигде не резолвится (как и задумано для Task 6) — конструктор с `InvalidOperationException` при отсутствии `ConnectionStrings:DealPostgres` сработает только с Task 7 (appsettings). 2. `ConnectionStringProvider` зарегистрирован в DI, но нигде не резолвится (как и задумано для Task 6) — конструктор с `InvalidOperationException` при отсутствии `ConnectionStrings:DealPostgres` сработает только с Task 7 (appsettings).
3. NuGet-пакет выбран как `10.0.11` (latest stable, совместим с net10.0). Версия не зафиксирована в плане; при централизованном управлении пакетами (CPM) отсутствует — правок не требуется. 3. NuGet-пакет выбран как `10.0.11` (latest stable, совместим с net10.0). Версия не зафиксирована в плане; при централизованном управлении пакетами (CPM) отсутствует — правок не требуется.
4. Загруженных файлов-маркеров (`SharedKernelMarker.cs`, `InfrastructureMarker.cs`) не касались. 4. Загруженных файлов-маркеров (`SharedKernelMarker.cs`, `InfrastructureMarker.cs`) не касались.
+91 -91
View File
@@ -1,91 +1,91 @@
# Task 7 — EF Core + миграции (public) (отчёт) # Task 7 — EF Core + миграции (public) (отчёт)
**Дата:** 2026-09-05 **Дата:** 2026-09-05
**Статус:** ✅ Выполнено (build 0 warnings / 0 errors; 4 теста PASS; миграция `InitialPublic` применена к `public`) **Статус:** ✅ Выполнено (build 0 warnings / 0 errors; 4 теста PASS; миграция `InitialPublic` применена к `public`)
## Файлы ## Файлы
### Созданы (по коду плана, verbatim) ### Созданы (по коду плана, verbatim)
- `src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs``TenantEntity` (`Guid Id`, `Name = string.Empty`, `Status = "active"`, `CreatedAt`). - `src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs``TenantEntity` (`Guid Id`, `Name = string.Empty`, `Status = "active"`, `CreatedAt`).
- `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs``DealDbContext` + `DbSet<TenantEntity> Tenants`; в `OnModelCreating`: `ToTable("tenants", "public")`, `HasKey(Id)`, `Name` max 200 / required. - `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs``DealDbContext` + `DbSet<TenantEntity> Tenants`; в `OnModelCreating`: `ToTable("tenants", "public")`, `HasKey(Id)`, `Name` max 200 / required.
- `src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs``IDesignTimeDbContextFactory<DealDbContext>`; строка из env `DEAL_PG_CONNECTION`, fallback `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password` (рабочий dev-Postgres Task 5). - `src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs``IDesignTimeDbContextFactory<DealDbContext>`; строка из env `DEAL_PG_CONNECTION`, fallback `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password` (рабочий dev-Postgres Task 5).
- `src/core/Deal.Infrastructure/Migrations/20260905190044_InitialPublic.cs` + `.Designer.cs` + `DealDbContextModelSnapshot.cs` — сгенерированы `dotnet ef migrations add InitialPublic`. - `src/core/Deal.Infrastructure/Migrations/20260905190044_InitialPublic.cs` + `.Designer.cs` + `DealDbContextModelSnapshot.cs` — сгенерированы `dotnet ef migrations add InitialPublic`.
- `tests/Deal.Tests.Unit/TenantEntityTests.cs` — тест дефолтов (см. Concern 3: строка `Assert.NotEqual` из плана скорректирована). - `tests/Deal.Tests.Unit/TenantEntityTests.cs` — тест дефолтов (см. Concern 3: строка `Assert.NotEqual` из плана скорректирована).
### Изменены ### Изменены
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлены пакеты (см. ниже). - `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлены пакеты (см. ниже).
- `src/core/Deal.Api/Deal.Api.csproj` — добавлен `Microsoft.EntityFrameworkCore.Design` 10.0.11 с `PrivateAssets=all` (см. Concern 2). - `src/core/Deal.Api/Deal.Api.csproj` — добавлен `Microsoft.EntityFrameworkCore.Design` 10.0.11 с `PrivateAssets=all` (см. Concern 2).
- `src/core/Deal.Api/Program.cs` — добавлены `using Deal.Infrastructure.Persistence;` и `using Microsoft.EntityFrameworkCore;`; после `CreateBuilder` добавлены `GetConnectionString("DealPostgres")` с тем же fallback и `builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));`. Существующие 2 singleton-регистрации, `/api/health` и `public partial class Program` не тронуты. - `src/core/Deal.Api/Program.cs` — добавлены `using Deal.Infrastructure.Persistence;` и `using Microsoft.EntityFrameworkCore;`; после `CreateBuilder` добавлены `GetConnectionString("DealPostgres")` с тем же fallback и `builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));`. Существующие 2 singleton-регистрации, `/api/health` и `public partial class Program` не тронуты.
- `src/core/Deal.Api/appsettings.Development.json` — файл уже существовал (стандартный `Logging` от шаблона); блок `ConnectionStrings:DealPostgres` **добавлен к существующему содержимому** (merge, а не замена). - `src/core/Deal.Api/appsettings.Development.json` — файл уже существовал (стандартный `Logging` от шаблона); блок `ConnectionStrings:DealPostgres` **добавлен к существующему содержимому** (merge, а не замена).
- `tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — добавлен ProjectReference на `Deal.Infrastructure` (Ruling 3, командой `dotnet add`). - `tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — добавлен ProjectReference на `Deal.Infrastructure` (Ruling 3, командой `dotnet add`).
## Пакеты NuGet (версии, resolved latest stable для net10.0) ## Пакеты NuGet (версии, resolved latest stable для net10.0)
Deal.Infrastructure: Deal.Infrastructure:
- `Microsoft.EntityFrameworkCore` **10.0.11** - `Microsoft.EntityFrameworkCore` **10.0.11**
- `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all, добавлен `dotnet add` автоматически) - `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all, добавлен `dotnet add` автоматически)
- `Microsoft.EntityFrameworkCore.Relational` **10.0.11** (см. Concern 1 — добавлен сверх списка плана) - `Microsoft.EntityFrameworkCore.Relational` **10.0.11** (см. Concern 1 — добавлен сверх списка плана)
- `Npgsql.EntityFrameworkCore.PostgreSQL` **10.0.3** - `Npgsql.EntityFrameworkCore.PostgreSQL` **10.0.3**
- (ранее, Task 6) `Microsoft.Extensions.Configuration.Abstractions` 10.0.11 - (ранее, Task 6) `Microsoft.Extensions.Configuration.Abstractions` 10.0.11
Deal.Api: Deal.Api:
- `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all; см. Concern 2) - `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all; см. Concern 2)
## dotnet-ef: локальный tool ## dotnet-ef: локальный tool
Глобально dotnet-ef **не установлен** (`dotnet tool list --global`: только `dotnet-dump`, `ilspycmd`). По Ruling 5 создан локальный манифест: Глобально dotnet-ef **не установлен** (`dotnet tool list --global`: только `dotnet-dump`, `ilspycmd`). По Ruling 5 создан локальный манифест:
- `dotnet new tool-manifest` → SDK 10 создал манифест **в `src/core/dotnet-tools.json`** (не в `.config/`, как в документации SDK 8/9). - `dotnet new tool-manifest` → SDK 10 создал манифест **в `src/core/dotnet-tools.json`** (не в `.config/`, как в документации SDK 8/9).
- `dotnet tool install dotnet-ef`**dotnet-ef 10.0.11**, запись добавлена в `src/core/dotnet-tools.json` (`isRoot: true`). Никакой манифест «выше» не найден — `dotnet new tool-manifest` сообщений о существующем не выдавал. - `dotnet tool install dotnet-ef`**dotnet-ef 10.0.11**, запись добавлена в `src/core/dotnet-tools.json` (`isRoot: true`). Никакой манифест «выше» не найден — `dotnet new tool-manifest` сообщений о существующем не выдавал.
## Миграция и БД ## Миграция и БД
Команды (из `src/core`, локальный tool): Команды (из `src/core`, локальный tool):
``` ```
dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api
Build started... Build succeeded. Done. Build started... Build succeeded. Done.
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api
Applying migration '20260905190044_InitialPublic'. Done. Applying migration '20260905190044_InitialPublic'. Done.
``` ```
Проверка (docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"): Проверка (docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"):
``` ```
Schema | Name | Type | Owner Schema | Name | Type | Owner
--------+-----------------------+-------+------- --------+-----------------------+-------+-------
public | __EFMigrationsHistory | table | deal public | __EFMigrationsHistory | table | deal
public | tenants | table | deal public | tenants | table | deal
(2 rows) (2 rows)
``` ```
Дизайн-тайм фабрика использовала fallback-строку (env `DEAL_PG_CONNECTION` не задан). Дизайн-тайм фабрика использовала fallback-строку (env `DEAL_PG_CONNECTION` не задан).
## Build ## Build
Команда: `dotnet build Deal.sln` (из `src/core`) Команда: `dotnet build Deal.sln` (из `src/core`)
``` ```
Deal.SharedKernel net10.0 успешно выполнено Deal.SharedKernel net10.0 успешно выполнено
... ...
Deal.Api net10.0 успешно выполнено (0,4 с) Deal.Api net10.0 успешно выполнено (0,4 с)
Сборка успешно выполнено через 2,3 с Сборка успешно выполнено через 2,3 с
``` ```
Результат: **0 warnings, 0 errors** (11 проектов). Результат: **0 warnings, 0 errors** (11 проектов).
## Tests ## Tests
Команда: `dotnet test tests/Deal.Tests.Unit` Команда: `dotnet test tests/Deal.Tests.Unit`
``` ```
Сводка теста: всего: 4; сбой: 0; успешно: 4; пропущено: 0; длительность: 1,0 с Сводка теста: всего: 4; сбой: 0; успешно: 4; пропущено: 0; длительность: 1,0 с
``` ```
PASS: `MarkerTests` (1) + `TenantIdTests` (2) + `TenantEntityTests` (1) = ровно 4. PASS: `MarkerTests` (1) + `TenantIdTests` (2) + `TenantEntityTests` (1) = ровно 4.
## Concerns / заметки ## Concerns / заметки
1. **`Microsoft.EntityFrameworkCore.Relational` 10.0.11 добавлен явно (сверх 3 пакетов плана).** Причина: пакет `Microsoft.EntityFrameworkCore` 10.0.11 НЕ зависит от Relational (его nuspec тянет только Abstractions/Analyzers/Caching.Memory/Logging). Единственным источником Relational в графе `Deal.Api` оказывается Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 с диапазоном `[10.0.4, 11.0.0)`; NuGet выбирает минимальную версию диапазона → Relational 10.0.4, тогда как `Deal.Infrastructure` собирается против 10.0.11 (через Design 10.0.11 с PrivateAssets=all — виден только в собственном графе). Итог: MSB3277 (конфликт версий Relational) в сборке `Deal.Api`. Явная ссылка `Relational` 10.0.11 в Infrastructure (транзитивно утекает в Api) выравнивает все EF-сборки на 10.0.11. MSB3277 — MSBuild-warning (не C#), поэтому TreatWarningsAsErrors его не превращал в error, но требование «0 warnings» нарушалось. 1. **`Microsoft.EntityFrameworkCore.Relational` 10.0.11 добавлен явно (сверх 3 пакетов плана).** Причина: пакет `Microsoft.EntityFrameworkCore` 10.0.11 НЕ зависит от Relational (его nuspec тянет только Abstractions/Analyzers/Caching.Memory/Logging). Единственным источником Relational в графе `Deal.Api` оказывается Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 с диапазоном `[10.0.4, 11.0.0)`; NuGet выбирает минимальную версию диапазона → Relational 10.0.4, тогда как `Deal.Infrastructure` собирается против 10.0.11 (через Design 10.0.11 с PrivateAssets=all — виден только в собственном графе). Итог: MSB3277 (конфликт версий Relational) в сборке `Deal.Api`. Явная ссылка `Relational` 10.0.11 в Infrastructure (транзитивно утекает в Api) выравнивает все EF-сборки на 10.0.11. MSB3277 — MSBuild-warning (не C#), поэтому TreatWarningsAsErrors его не превращал в error, но требование «0 warnings» нарушалось.
2. **`Microsoft.EntityFrameworkCore.Design` добавлен в `Deal.Api` (PrivateAssets=all).** `dotnet ef` требует Design-пакет в **startup**-проекте: `Your startup project 'Deal.Api' doesn't reference Microsoft.EntityFrameworkCore.Design...`. Ссылка в Infrastructure имеет `PrivateAssets=all` и в Api не утекает, поэтому первая попытка `migrations add` упала с этой ошибкой. После добавления в Api миграция создалась штатно (дизайн-тайм фабрика при этом по-прежнему из Infrastructure). 2. **`Microsoft.EntityFrameworkCore.Design` добавлен в `Deal.Api` (PrivateAssets=all).** `dotnet ef` требует Design-пакет в **startup**-проекте: `Your startup project 'Deal.Api' doesn't reference Microsoft.EntityFrameworkCore.Design...`. Ссылка в Infrastructure имеет `PrivateAssets=all` и в Api не утекает, поэтому первая попытка `migrations add` упала с этой ошибкой. После добавления в Api миграция создалась штатно (дизайн-тайм фабрика при этом по-прежнему из Infrastructure).
3. **Тест скорректирован (единственное отступление от verbatim-кода плана).** Строка плана `Assert.NotEqual(Guid.Empty, entity.Id == Guid.Empty ? Guid.Empty : entity.Id);` невыполнима: `TenantEntity.Id` — автосвойство `Guid` без инициализатора → всегда `Guid.Empty`, тернарник всегда возвращает `Guid.Empty`, и `NotEqual(Guid.Empty, Guid.Empty)` падает (проверено: 1 FAIL на verbatim-версии). План под кодом сам разрешает правку: «тест проверяет дефолты; при необходимости скорректировать под реальную модель». Строка заменена на проверки реальных дефолтов: `Status == "active"`, `Name == string.Empty`, `CreatedAt == default(DateTimeOffset)`. Ожидание Task 13 (4 PASS) при verbatim-строке недостижимо. 3. **Тест скорректирован (единственное отступление от verbatim-кода плана).** Строка плана `Assert.NotEqual(Guid.Empty, entity.Id == Guid.Empty ? Guid.Empty : entity.Id);` невыполнима: `TenantEntity.Id` — автосвойство `Guid` без инициализатора → всегда `Guid.Empty`, тернарник всегда возвращает `Guid.Empty`, и `NotEqual(Guid.Empty, Guid.Empty)` падает (проверено: 1 FAIL на verbatim-версии). План под кодом сам разрешает правку: «тест проверяет дефолты; при необходимости скорректировать под реальную модель». Строка заменена на проверки реальных дефолтов: `Status == "active"`, `Name == string.Empty`, `CreatedAt == default(DateTimeOffset)`. Ожидание Task 13 (4 PASS) при verbatim-строке недостижимо.
4. `appsettings.Development.json` уже существовал (Logging-блок); `ConnectionStrings` добавлены merge-правкой — стандартный шаблонный блок сохранён. 4. `appsettings.Development.json` уже существовал (Logging-блок); `ConnectionStrings` добавлены merge-правкой — стандартный шаблонный блок сохранён.
5. `.editorconfig`/`Directory.Build.props` не редактировались. Генерённые миграционные файлы (`.Designer.cs`, Snapshot) предупреждений при сборке не дают. 5. `.editorconfig`/`Directory.Build.props` не редактировались. Генерённые миграционные файлы (`.Designer.cs`, Snapshot) предупреждений при сборке не дают.
6. Проект не является git-репозиторием: коммиты/ветки не создавались (как и во всех предыдущих задачах). 6. Проект не является git-репозиторием: коммиты/ветки не создавались (как и во всех предыдущих задачах).
+42 -42
View File
@@ -1,42 +1,42 @@
# Task 8 Report: Применение миграций ко всем схемам тенантов # Task 8 Report: Применение миграций ко всем схемам тенантов
**Status:** DONE (with one documented deviation in the test file — see Concerns) **Status:** DONE (with one documented deviation in the test file — see Concerns)
## Files ## Files
| File | Action | | File | Action |
|---|---| |---|---|
| `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs` | Created (verbatim, plan lines 736751) | | `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs` | Created (verbatim, plan lines 736751) |
| `src/core/tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs` | Created (test 1 verbatim; test 2 assertion repaired — see Concerns) | | `src/core/tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs` | Created (test 1 verbatim; test 2 assertion repaired — see Concerns) |
`TenantSchemaMigrator` is a single public static type in its own file with XML-doc from the plan. `TenantSchemaMigrator` is a single public static type in its own file with XML-doc from the plan.
`CreateSchemaSql` uses doubled-quote identifier escaping (`Replace("\"", "\"\"")`); `ListTenantSchemasSql` is verbatim. Production code NOT modified relative to the plan. `CreateSchemaSql` uses doubled-quote identifier escaping (`Replace("\"", "\"\"")`); `ListTenantSchemasSql` is verbatim. Production code NOT modified relative to the plan.
## Build output ## Build output
`dotnet build Deal.sln` — succeeded: 11/11 projects, **0 warnings / 0 errors**. `dotnet build Deal.sln` — succeeded: 11/11 projects, **0 warnings / 0 errors**.
## Test output ## Test output
`dotnet test tests/Deal.Tests.Unit --no-build`**6 PASS / 0 FAIL** (4 existing + 2 new `TenantSchemaMigratorTests`). `dotnet test tests/Deal.Tests.Unit --no-build`**6 PASS / 0 FAIL** (4 existing + 2 new `TenantSchemaMigratorTests`).
## Concerns / Deviation ## Concerns / Deviation
1. **Plan's test assertion was self-contradictory (verbatim code + verbatim test could not pass).** 1. **Plan's test assertion was self-contradictory (verbatim code + verbatim test could not pass).**
Plan test 2 asserted `Assert.DoesNotContain("\"b\"", sql)` for input `tenant_a"b`. Plan test 2 asserted `Assert.DoesNotContain("\"b\"", sql)` for input `tenant_a"b`.
With the sanctioned doubling escape the output is `CREATE SCHEMA IF NOT EXISTS "tenant_a""b"`, which With the sanctioned doubling escape the output is `CREATE SCHEMA IF NOT EXISTS "tenant_a""b"`, which
necessarily *contains* the substring `"b"` (2nd quote of the escaped pair `""``b` → closing `"`). necessarily *contains* the substring `"b"` (2nd quote of the escaped pair `""``b` → closing `"`).
The assertion fails for any implementation that keeps the quote inside the identifier, and would even The assertion fails for any implementation that keeps the quote inside the identifier, and would even
pass for a quote-*stripping* bug — it tests nothing. The plan's own Step 3 criterion ("expected 6 PASS") pass for a quote-*stripping* bug — it tests nothing. The plan's own Step 3 criterion ("expected 6 PASS")
was therefore unreachable with that literal assertion. was therefore unreachable with that literal assertion.
2. **Fix applied (test-only, minimal):** replaced the broken single assertion with two meaningful ones: 2. **Fix applied (test-only, minimal):** replaced the broken single assertion with two meaningful ones:
`Assert.Contains("\"tenant_a\"\"b\"", sql)` (quote is doubled) and `Assert.Contains("\"tenant_a\"\"b\"", sql)` (quote is doubled) and
`Assert.DoesNotContain("\"tenant_a\"b\"", sql)` (raw unescaped form is absent). `Assert.DoesNotContain("\"tenant_a\"b\"", sql)` (raw unescaped form is absent).
Production code untouched. Precedent for adjusting plan tests to reality exists in plan Task 7's note. Production code untouched. Precedent for adjusting plan tests to reality exists in plan Task 7's note.
3. First test (`CreateSchemaSql_IsEscaped`) kept verbatim and passes. 3. First test (`CreateSchemaSql_IsEscaped`) kept verbatim and passes.
4. 1 public type = 1 file respected. `Directory.Build.props` untouched. No git operations performed. 4. 1 public type = 1 file respected. `Directory.Build.props` untouched. No git operations performed.
## Verification ## Verification
- Build: `dotnet build Deal.sln` → «Сборка успешно выполнено», 0 warnings/errors. - Build: `dotnet build Deal.sln` → «Сборка успешно выполнено», 0 warnings/errors.
- Tests: `dotnet test tests/Deal.Tests.Unit --no-build` → «всего: 6; сбой: 0; успешно: 6». - Tests: `dotnet test tests/Deal.Tests.Unit --no-build` → «всего: 6; сбой: 0; успешно: 6».
+53 -53
View File
@@ -1,53 +1,53 @@
# Task 9 Report: CI-скрипты и финальная проверка этапа # Task 9 Report: CI-скрипты и финальная проверка этапа
**Status:** DONE — scripts created verbatim, both pass, all four final checks green. No deviations. **Status:** DONE — scripts created verbatim, both pass, all four final checks green. No deviations.
## Files ## Files
| File | Action | | File | Action |
|---|---| |---|---|
| `scripts/build.sh` | Created (verbatim, plan lines 774778) | | `scripts/build.sh` | Created (verbatim, plan lines 774778) |
| `scripts/test.sh` | Created (verbatim, plan lines 782787) | | `scripts/test.sh` | Created (verbatim, plan lines 782787) |
| `.superpowers/sdd/deal-scaffold/progress.md` | Appended Task 9 ledger line after Task 8 (nothing else touched) | | `.superpowers/sdd/deal-scaffold/progress.md` | Appended Task 9 ledger line after Task 8 (nothing else touched) |
Script contents (both use `sh`, `set -e`, resolve repo root via `$(dirname "$0")/..`): Script contents (both use `sh`, `set -e`, resolve repo root via `$(dirname "$0")/..`):
- `scripts/build.sh``cd src/core && dotnet build Deal.sln` - `scripts/build.sh``cd src/core && dotnet build Deal.sln`
- `scripts/test.sh``cd src/core && dotnet test tests/Deal.Tests.Unit` - `scripts/test.sh``cd src/core && dotnet test tests/Deal.Tests.Unit`
No project/source files modified. No git operations (project is not a git repo). No project/source files modified. No git operations (project is not a git repo).
## Script run output (Step 3, from `C:\telbase`) ## Script run output (Step 3, from `C:\telbase`)
`sh scripts/build.sh` — succeeded: 11/11 projects built, «Сборка успешно выполнено через 1,9 с», 0 warnings/0 errors. `sh scripts/build.sh` — succeeded: 11/11 projects built, «Сборка успешно выполнено через 1,9 с», 0 warnings/0 errors.
`sh scripts/test.sh` — succeeded: «Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0; длительность: 1,0 с» (build 2,9 с). `sh scripts/test.sh` — succeeded: «Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0; длительность: 1,0 с» (build 2,9 с).
## Final stage verification (Step 4) ## Final stage verification (Step 4)
| # | Check | Result | | # | Check | Result |
|---|---|---| |---|---|---|
| 1 | `dotnet build Deal.sln` (src/core) | PASS — 11/11 projects, 0 errors, 0 warnings | | 1 | `dotnet build Deal.sln` (src/core) | PASS — 11/11 projects, 0 errors, 0 warnings |
| 2 | `dotnet test tests/Deal.Tests.Unit` (src/core) | PASS — 6 PASS / 0 FAIL / 0 skipped | | 2 | `dotnet test tests/Deal.Tests.Unit` (src/core) | PASS — 6 PASS / 0 FAIL / 0 skipped |
| 3 | `docker ps``deal-postgres` | PASS — «Up 12 minutes (healthy)» | | 3 | `docker ps``deal-postgres` | PASS — «Up 12 minutes (healthy)» |
| 4 | `curl http://localhost:5080/api/health` | PASS — `{"ok":true,"service":"deal"}`; process killed, port released | | 4 | `curl http://localhost:5080/api/health` | PASS — `{"ok":true,"service":"deal"}`; process killed, port released |
Check 4 detail (background pattern from earlier tasks): Check 4 detail (background pattern from earlier tasks):
`dotnet run --project Deal.Api --no-build --urls http://localhost:5080 &` → sleep 8 → `dotnet run --project Deal.Api --no-build --urls http://localhost:5080 &` → sleep 8 →
`curl -s http://localhost:5080/api/health` returned `{"ok":true,"service":"deal"}` `curl -s http://localhost:5080/api/health` returned `{"ok":true,"service":"deal"}`
`kill $APP_PID` (PID 1384) → follow-up after 2 s: `curl -m 3` exit 7 (connection refused), `kill $APP_PID` (PID 1384) → follow-up after 2 s: `curl -m 3` exit 7 (connection refused),
`netstat -ano` shows no LISTENING socket on :5080 (only a client-side TIME_WAIT entry, PID 0). `netstat -ano` shows no LISTENING socket on :5080 (only a client-side TIME_WAIT entry, PID 0).
App log tail confirms clean startup («Application started. Press Ctrl+C to shut down.»), no errors. App log tail confirms clean startup («Application started. Press Ctrl+C to shut down.»), no errors.
## Concerns ## Concerns
- None blocking. Minor notes: - None blocking. Minor notes:
- `curl` exit code (7) and netstat were used to confirm port release; a residual client-side - `curl` exit code (7) and netstat were used to confirm port release; a residual client-side
`TIME_WAIT` socket on the ephemeral port is expected and self-clears — no listening socket remains. `TIME_WAIT` socket on the ephemeral port is expected and self-clears — no listening socket remains.
- Scripts verified under the environment's `sh` (Windows); they make no assumptions beyond POSIX `sh` - Scripts verified under the environment's `sh` (Windows); they make no assumptions beyond POSIX `sh`
and `dotnet` on PATH, so they are CI-portable. No chmod needed on this platform. and `dotnet` on PATH, so they are CI-portable. No chmod needed on this platform.
- `dotnet run` log redirected to `/tmp/deal-api-task9.log` during the health check for diagnosability. - `dotnet run` log redirected to `/tmp/deal-api-task9.log` during the health check for diagnosability.
## Verification ## Verification
- Ledger updated: `.superpowers/sdd/deal-scaffold/progress.md` → Task 9 line appended under «## Task status». - Ledger updated: `.superpowers/sdd/deal-scaffold/progress.md` → Task 9 line appended under «## Task status».
- This report supersedes as the record for Task 9; no project files changed by this task. - This report supersedes as the record for Task 9; no project files changed by this task.
@@ -1,43 +1,43 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md # SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md
Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger. Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger.
Ревью — по фактическим файлам дерева (diff-пакетов нет). Ревью — по фактическим файлам дерева (diff-пакетов нет).
## Todos ## Todos
- [x] Task 1: Карта API (выполнена до плана — `docs/api/api-map.md`) - [x] Task 1: Карта API (выполнена до плана — `docs/api/api-map.md`)
- [x] Task 2: Персистентность — системный и tenant-контексты, миграции - [x] Task 2: Персистентность — системный и tenant-контексты, миграции
- Task 2: complete (review clean). Minor: (1) tenant-миграции легли в `Migrations/TenantDb/` (namespace `.TenantDb`) — нормализовать при желании через `--output-dir`; (2) Ruling 9 колонки PascalCase/`varchar(200)` вместо буквального SQL — согласовано с конвенцией кода; (3) `IX_users_Login` глобально-уникален — by design. - Task 2: complete (review clean). Minor: (1) tenant-миграции легли в `Migrations/TenantDb/` (namespace `.TenantDb`) — нормализовать при желании через `--output-dir`; (2) Ruling 9 колонки PascalCase/`varchar(200)` вместо буквального SQL — согласовано с конвенцией кода; (3) `IX_users_Login` глобально-уникален — by design.
- [x] Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации - [x] Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации
- Task 3: complete (review clean; 25 PASS). Minor: (1) константы сессии/длины пароля приватны в AuthService — в Task 4 согласовать снаружи (IOptions) для cookie; (2) нормализация login lowercase — осознанно строже прототипа. - Task 3: complete (review clean; 25 PASS). Minor: (1) константы сессии/длины пароля приватны в AuthService — в Task 4 согласовать снаружи (IOptions) для cookie; (2) нормализация login lowercase — осознанно строже прототипа.
- [x] Task 4: Эндпоинты auth, middleware сессии, DI, curl-приёмка - [x] Task 4: Эндпоинты auth, middleware сессии, DI, curl-приёмка
- Task 4: complete (review clean; 25 PASS, curl 12/12). Minor: (1) `UnsafeRelaxedJsonEscaping` — ок для dev; (2) «30 дней» дублируется (AuthService vs Cookies) — унифицировать в Task 5; (3) Cookies продублирована в appsettings.json — осознанно. Временные заглушки StartupSeed через DealDbContext + PendingTenantProvisioner помечены «удалить в Task 5». - Task 4: complete (review clean; 25 PASS, curl 12/12). Minor: (1) `UnsafeRelaxedJsonEscaping` — ок для dev; (2) «30 дней» дублируется (AuthService vs Cookies) — унифицировать в Task 5; (3) Cookies продублирована в appsettings.json — осознанно. Временные заглушки StartupSeed через DealDbContext + PendingTenantProvisioner помечены «удалить в Task 5».
- Task 4: complete (review clean; build 0/0, тесты 25 PASS, curl-приёмка :5080 — два прогона). - Task 4: complete (review clean; build 0/0, тесты 25 PASS, curl-приёмка :5080 — два прогона).
Minor: (1) временная DI-заглушка PendingTenantProvisioner до Task 5 (on-build валидация TenantService); Minor: (1) временная DI-заглушка PendingTenantProvisioner до Task 5 (on-build валидация TenantService);
(2) seed создаёт пользователя через DealDbContext — в IAuthStore нет CreateUser; (3) Cookies-секция (2) seed создаёт пользователя через DealDbContext — в IAuthStore нет CreateUser; (3) Cookies-секция
добавлена и в базовый appsettings.json (иначе Days=0 вне Development). Отчёт: task-4-report.md. добавлена и в базовый appsettings.json (иначе Days=0 вне Development). Отчёт: task-4-report.md.
- [x] Task 5: Провижининг схем тенантов и bootstrap при старте - [x] Task 5: Провижининг схем тенантов и bootstrap при старте
- Task 5: complete (review clean; build 0/0, тесты 25 PASS, приёмка :5080 — два старта). - Task 5: complete (review clean; build 0/0, тесты 25 PASS, приёмка :5080 — два старта).
Minor: (1) psql-колонки PascalCase (EF default) — буквальные lowercase-запросы из задания падают, см. отчёт; Minor: (1) psql-колонки PascalCase (EF default) — буквальные lowercase-запросы из задания падают, см. отчёт;
(2) запуск apphost Deal.Api.exe напрямую вместо `dotnet run` (детерминированная остановка); (2) запуск apphost Deal.Api.exe напрямую вместо `dotnet run` (детерминированная остановка);
(3) «30» — единый источник AuthService.SessionLifetimeDays; Cookies:Days убран из appsettings. (3) «30» — единый источник AuthService.SessionLifetimeDays; Cookies:Days убран из appsettings.
Отчёт: task-5-report.md. Отчёт: task-5-report.md.
- [x] Task 6: Финал этапа - [x] Task 6: Финал этапа
- Task 6: complete (review clean; техдок §13/§11/§4 обновлены). Отчёт: task-6-report.md. - Task 6: complete (review clean; техдок §13/§11/§4 обновлены). Отчёт: task-6-report.md.
- **Этап 1 завершён**: финальное whole-scope ревью ✅ (build 0/0, 25 PASS, psql-схемы, live-curl auth 1:1, техдок фактичен). Миноры в этап 2: (1) ConnectionStrings:DealPostgres только в Development — вне dev нужен env; (2) tenant-миграции в `Migrations/TenantDb/`; (3) IX_users_Login глобально-уникален; (4) имя куки `deal_session` — при подключении реального фронта сверить. - **Этап 1 завершён**: финальное whole-scope ревью ✅ (build 0/0, 25 PASS, psql-схемы, live-curl auth 1:1, техдок фактичен). Миноры в этап 2: (1) ConnectionStrings:DealPostgres только в Development — вне dev нужен env; (2) tenant-миграции в `Migrations/TenantDb/`; (3) IX_users_Login глобально-уникален; (4) имя куки `deal_session` — при подключении реального фронта сверить.
## Pre-flight scan ## Pre-flight scan
| Пара | Производит / потребляет | Результат | | Пара | Производит / потребляет | Результат |
|---|---|---| |---|---|---|
| T2 → T3 | T2 создаёт EF-сущности users/sessions/tenants/settings; T3-реализации (AuthStore) их читают | Чисто (реализации в Infrastructure видят Entities) | | T2 → T3 | T2 создаёт EF-сущности users/sessions/tenants/settings; T3-реализации (AuthStore) их читают | Чисто (реализации в Infrastructure видят Entities) |
| T3 → T4 | T4-эндпоинты зовут AuthService модуля | Чисто | | T3 → T4 | T4-эндпоинты зовут AuthService модуля | Чисто |
| T4 → T5 | T5 bootstrap создаёт дефолтного пользователя, которого логинит T4-приёмка | T5 идёт после T4; в T4 для curl-приёмки нужен seed admin — см. Ruling 8, внесён в T5. **Конфликт**: curl-приёмка T4 требует пользователя, которого создаёт T5. Резолв: T4 делает минимальный inline-seed (users) через тот же код bootstrap-хелпера, T5 формализует провижининг схем. | | T4 → T5 | T5 bootstrap создаёт дефолтного пользователя, которого логинит T4-приёмка | T5 идёт после T4; в T4 для curl-приёмки нужен seed admin — см. Ruling 8, внесён в T5. **Конфликт**: curl-приёмка T4 требует пользователя, которого создаёт T5. Резолв: T4 делает минимальный inline-seed (users) через тот же код bootstrap-хелпера, T5 формализует провижининг схем. |
| T2 → T5 | T5 применяет tenant-миграции из T2 | Чисто | | T2 → T5 | T5 применяет tenant-миграции из T2 | Чисто |
| T2 → T2 | пересоздание dev-БД (Ruling 4) | Dev-данных нет — безопасно | | T2 → T2 | пересоздание dev-БД (Ruling 4) | Dev-данных нет — безопасно |
| T3 | модуль не ссылается на Infrastructure | Проверить в ревью (циклов быть не должно) | | T3 | модуль не ссылается на Infrastructure | Проверить в ревью (циклов быть не должно) |
| T5 | `MigrationsHistoryTable("__TenantMigrationsHistory", schema)` | Подтвердить в ревью фактическим применением | | T5 | `MigrationsHistoryTable("__TenantMigrationsHistory", schema)` | Подтвердить в ревью фактическим применением |
| T6 → T2..T5 | финальные проверки | Чисто | | T6 → T2..T5 | финальные проверки | Чисто |
## Task status ## Task status
- Task 6: complete (review pending). Отчёт: task-6-report.md. - Task 6: complete (review pending). Отчёт: task-6-report.md.
@@ -1,230 +1,230 @@
# Task 2 — Персистентность: системный и tenant-контексты, миграции. Отчёт # Task 2 — Персистентность: системный и tenant-контексты, миграции. Отчёт
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`. Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
## Итог ## Итог
Статус: **DONE_WITH_CONCERNS** (см. «Отклонения»). Сборка 0 warnings/0 errors, тесты 6 PASS, Статус: **DONE_WITH_CONCERNS** (см. «Отклонения»). Сборка 0 warnings/0 errors, тесты 6 PASS,
dev-БД пересоздана (public: tenants, users, sessions, `__EFMigrationsHistory` c одной строкой dev-БД пересоздана (public: tenants, users, sessions, `__EFMigrationsHistory` c одной строкой
`InitialSystem`), tenant-миграция `InitialTenant` создана, SQL без схемы, не применялась `InitialSystem`), tenant-миграция `InitialTenant` создана, SQL без схемы, не применялась
(применение — Task 5). (применение — Task 5).
## Файлы ## Файлы
### Созданы ### Созданы
- `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs` — POCO пользователя - `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs` — POCO пользователя
(Id = `Guid.NewGuid()` на клиенте, Login, TenantId, PasswordHash, Status = "active", CreatedAt). (Id = `Guid.NewGuid()` на клиенте, Login, TenantId, PasswordHash, Status = "active", CreatedAt).
- `src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs` — POCO сессии - `src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs` — POCO сессии
(TokenHash — PK, UserId, Login-денормализация, ExpiresAt, CreatedAt). (TokenHash — PK, UserId, Login-денормализация, ExpiresAt, CreatedAt).
- `src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs` — POCO настройки - `src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs` — POCO настройки
тенанта (Key — PK, ValueJson, UpdatedAt). 1 тип = 1 файл. тенанта (Key — PK, ValueJson, UpdatedAt). 1 тип = 1 файл.
- `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs``ToTable("users","public")`, - `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs``ToTable("users","public")`,
PK Id, уникальный индекс Login, индекс TenantId, `PasswordHash` text, `Status` default "active", PK Id, уникальный индекс Login, индекс TenantId, `PasswordHash` text, `Status` default "active",
`CreatedAt` default `now()` (SQL), FK `users.TenantId → tenants.Id` ON DELETE RESTRICT. `CreatedAt` default `now()` (SQL), FK `users.TenantId → tenants.Id` ON DELETE RESTRICT.
- `src/core/Deal.Infrastructure/Persistence/SessionConfiguration.cs``ToTable("sessions","public")`, - `src/core/Deal.Infrastructure/Persistence/SessionConfiguration.cs``ToTable("sessions","public")`,
PK TokenHash (varchar(64)), индексы UserId и ExpiresAt, MaxLength/IsRequired, PK TokenHash (varchar(64)), индексы UserId и ExpiresAt, MaxLength/IsRequired,
FK `sessions.UserId → users.Id` ON DELETE CASCADE. FK `sessions.UserId → users.Id` ON DELETE CASCADE.
- `src/core/Deal.Infrastructure/Persistence/TenantSettingConfiguration.cs``ToTable("settings")` - `src/core/Deal.Infrastructure/Persistence/TenantSettingConfiguration.cs``ToTable("settings")`
БЕЗ схемы (модель бессхемная, живёт через search_path), PK Key (varchar(200)), БЕЗ схемы (модель бессхемная, живёт через search_path), PK Key (varchar(200)),
`ValueJson` text NOT NULL, `UpdatedAt` required. `ValueJson` text NOT NULL, `UpdatedAt` required.
- `src/core/Deal.Infrastructure/Persistence/TenantConfiguration.cs` — вынес конфигурацию - `src/core/Deal.Infrastructure/Persistence/TenantConfiguration.cs` — вынес конфигурацию
`TenantEntity` из `DealDbContext.OnModelCreating` для единообразия; маппинг НЕ изменён `TenantEntity` из `DealDbContext.OnModelCreating` для единообразия; маппинг НЕ изменён
(ToTable "tenants","public", PK Id, Name varchar(200) NOT NULL) — в рамках опции задачи «по желанию». (ToTable "tenants","public", PK Id, Name varchar(200) NOT NULL) — в рамках опции задачи «по желанию».
- `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — бессхемный DbContext тенанта; - `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — бессхемный DbContext тенанта;
DbSet `Settings`; применяет только `TenantSettingConfiguration`. DbSet `Settings`; применяет только `TenantSettingConfiguration`.
- `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs` — design-time фабрика - `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs` — design-time фабрика
для dotnet-ef; та же строка подключения (env `DEAL_PG_CONNECTION` или localhost:5433); для dotnet-ef; та же строка подключения (env `DEAL_PG_CONNECTION` или localhost:5433);
`.UseNpgsql(cs, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))` (без схемы). `.UseNpgsql(cs, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))` (без схемы).
### Изменены ### Изменены
- `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs` — добавлены DbSet `Users`, `Sessions`; - `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs` — добавлены DbSet `Users`, `Sessions`;
`OnModelCreating` применяет `TenantConfiguration/UserConfiguration/SessionConfiguration` `OnModelCreating` применяет `TenantConfiguration/UserConfiguration/SessionConfiguration`
(по одной на конфигурацию, без assembly-скана — чтобы не затащить `settings` в системную модель). (по одной на конфигурацию, без assembly-скана — чтобы не затащить `settings` в системную модель).
### Удалены ### Удалены
- `Migrations/20260905190044_InitialPublic.cs`, `...Designer.cs`, `Migrations/DealDbContextModelSnapshot.cs` - `Migrations/20260905190044_InitialPublic.cs`, `...Designer.cs`, `Migrations/DealDbContextModelSnapshot.cs`
(пересозданы начисто по Ruling 4). (пересозданы начисто по Ruling 4).
### Не менялись ### Не менялись
- `TenantEntity.cs`, `Data/TenantContext.cs`, `Data/ConnectionStringProvider.cs`, - `TenantEntity.cs`, `Data/TenantContext.cs`, `Data/ConnectionStringProvider.cs`,
`Migrations/TenantSchemaMigrator.cs`, `Deal.Api/Program.cs` (регистрации как были; `Migrations/TenantSchemaMigrator.cs`, `Deal.Api/Program.cs` (регистрации как были;
`TenantDbContext` в DI НЕ регистрировался — появится в Task 5), конфиги/тесты. `TenantDbContext` в DI НЕ регистрировался — появится в Task 5), конфиги/тесты.
## Команды и вывод ## Команды и вывод
Строка подключения: `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password` Строка подключения: `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password`
(env `DEAL_PG_CONNECTION` не задан — используется fallback фабрик). (env `DEAL_PG_CONNECTION` не задан — используется fallback фабрик).
### 1. Сброс схемы dev-БД (Ruling 4) ### 1. Сброс схемы dev-БД (Ruling 4)
``` ```
$ docker exec deal-postgres psql -U deal -d deal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" $ docker exec deal-postgres psql -U deal -d deal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
NOTICE: drop cascades to 2 other objects NOTICE: drop cascades to 2 other objects
DETAIL: drop cascades to table "__EFMigrationsHistory" DETAIL: drop cascades to table "__EFMigrationsHistory"
drop cascades to table tenants drop cascades to table tenants
DROP SCHEMA DROP SCHEMA
CREATE SCHEMA CREATE SCHEMA
``` ```
### 2. Удаление старых артефактов миграций ### 2. Удаление старых артефактов миграций
Удалены `20260905190044_InitialPublic.cs`, `20260905190044_InitialPublic.Designer.cs`, Удалены `20260905190044_InitialPublic.cs`, `20260905190044_InitialPublic.Designer.cs`,
`DealDbContextModelSnapshot.cs` из `Deal.Infrastructure/Migrations/`. `DealDbContextModelSnapshot.cs` из `Deal.Infrastructure/Migrations/`.
### 3. Создание миграций (из `src/core`) ### 3. Создание миграций (из `src/core`)
``` ```
$ dotnet ef migrations add InitialSystem --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext $ dotnet ef migrations add InitialSystem --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
Build started... Build started...
Build succeeded. Build succeeded.
Done. To undo this action, use 'ef migrations remove' Done. To undo this action, use 'ef migrations remove'
$ dotnet ef migrations add InitialTenant --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext $ dotnet ef migrations add InitialTenant --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
Build started... Build started...
Build succeeded. Build succeeded.
Done. To undo this action, use 'ef migrations remove' Done. To undo this action, use 'ef migrations remove'
``` ```
Появились: Появились:
- `Migrations/20260905192825_InitialSystem.cs` (+ `.Designer.cs`) — tenants+users+sessions в `public`; - `Migrations/20260905192825_InitialSystem.cs` (+ `.Designer.cs`) — tenants+users+sessions в `public`;
- `Migrations/DealDbContextModelSnapshot.cs`; - `Migrations/DealDbContextModelSnapshot.cs`;
- `Migrations/TenantDb/20260905193010_InitialTenant.cs` (+ `.Designer.cs`) — `settings`; - `Migrations/TenantDb/20260905193010_InitialTenant.cs` (+ `.Designer.cs`) — `settings`;
- `Migrations/TenantDb/TenantDbContextModelSnapshot.cs`. - `Migrations/TenantDb/TenantDbContextModelSnapshot.cs`.
(см. «Отклонения» — папка `TenantDb`, а не корень `Migrations/`.) (см. «Отклонения» — папка `TenantDb`, а не корень `Migrations/`.)
### 4. Применение системной миграции ### 4. Применение системной миграции
``` ```
$ dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext $ dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
Build started... Build started...
Build succeeded. Build succeeded.
Failed executing DbCommand (19ms) ... SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory" ... Failed executing DbCommand (19ms) ... SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory" ...
Acquiring an exclusive lock for migration application. ... Acquiring an exclusive lock for migration application. ...
Applying migration '20260905192825_InitialSystem'. Applying migration '20260905192825_InitialSystem'.
Done. Done.
``` ```
«Failed executing DbCommand» — ожидаемое штатное зондирование отсутствующей (после DROP SCHEMA) «Failed executing DbCommand» — ожидаемое штатное зондирование отсутствующей (после DROP SCHEMA)
таблицы истории перед применением; миграция применена успешно. таблицы истории перед применением; миграция применена успешно.
### 5. Проверка `InitialTenant` ### 5. Проверка `InitialTenant`
В файле миграции (`Migrations/TenantDb/20260905193010_InitialTenant.cs`) — только В файле миграции (`Migrations/TenantDb/20260905193010_InitialTenant.cs`) — только
`CreateTable(name: "settings", ...)`, БЕЗ параметра `schema`; grep по `schema|public|tenant_` `CreateTable(name: "settings", ...)`, БЕЗ параметра `schema`; grep по `schema|public|tenant_`
в трёх файлах tenant-миграции даёт 0 совпадений в SQL (единственное совпадение — ключевое слово в трёх файлах tenant-миграции даёт 0 совпадений в SQL (единственное совпадение — ключевое слово
C# `public` в `public partial class`). История миграций НЕ создаётся телом миграции — C# `public` в `public partial class`). История миграций НЕ создаётся телом миграции —
EF создаёт `__TenantMigrationsHistory` при применении (`Migrate`), что соответствует Ruling 3 EF создаёт `__TenantMigrationsHistory` при применении (`Migrate`), что соответствует Ruling 3
(в Task 5 таблица истории создастся в схеме тенанта). Миграция не применялась. (в Task 5 таблица истории создастся в схеме тенанта). Миграция не применялась.
## Проверки (Acceptance) ## Проверки (Acceptance)
### Build ### Build
``` ```
$ dotnet build Deal.sln --nologo $ dotnet build Deal.sln --nologo
Сборка успешно выполнено через 7,0 с # и повторно: 1,9 с Сборка успешно выполнено через 7,0 с # и повторно: 1,9 с
``` ```
0 warnings / 0 errors (TreatWarningsAsErrors, AnalysisLevel latest). 0 warnings / 0 errors (TreatWarningsAsErrors, AnalysisLevel latest).
### Tests ### Tests
``` ```
$ dotnet test tests/Deal.Tests.Unit --nologo $ dotnet test tests/Deal.Tests.Unit --nologo
Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0 Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0
``` ```
### `dotnet ef migrations list` (из `src/core`) ### `dotnet ef migrations list` (из `src/core`)
Без `--context` команда завершается ошибкой: Без `--context` команда завершается ошибкой:
``` ```
More than one DbContext was found. Specify which one to use. Use the '-Context' parameter for More than one DbContext was found. Specify which one to use. Use the '-Context' parameter for
PowerShell commands and the '--context' parameter for dotnet commands. PowerShell commands and the '--context' parameter for dotnet commands.
``` ```
По контекстам: По контекстам:
``` ```
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext $ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
20260905192825_InitialSystem 20260905192825_InitialSystem
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext $ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
Failed executing DbCommand ... SELECT "MigrationId", "ProductVersion" FROM "__TenantMigrationsHistory" ... Failed executing DbCommand ... SELECT "MigrationId", "ProductVersion" FROM "__TenantMigrationsHistory" ...
20260905193010_InitialTenant (Pending) 20260905193010_InitialTenant (Pending)
``` ```
`InitialSystem` — применена (без пометки Pending), `InitialTenant` — Pending `InitialSystem` — применена (без пометки Pending), `InitialTenant` — Pending
(таблицы истории в БД нет — это норма: она создаётся при применении в Task 5). (таблицы истории в БД нет — это норма: она создаётся при применении в Task 5).
### psql ### psql
``` ```
$ docker exec deal-postgres psql -U deal -d deal -c "\dt public.*" $ docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
List of relations List of relations
Schema | Name | Type | Owner Schema | Name | Type | Owner
--------+-----------------------+-------+------- --------+-----------------------+-------+-------
public | __EFMigrationsHistory | table | deal public | __EFMigrationsHistory | table | deal
public | sessions | table | deal public | sessions | table | deal
public | tenants | table | deal public | tenants | table | deal
public | users | table | deal public | users | table | deal
(4 rows) (4 rows)
$ docker exec deal-postgres psql -U deal -d deal -c "SELECT \"MigrationId\" FROM public.\"__EFMigrationsHistory\";" $ docker exec deal-postgres psql -U deal -d deal -c "SELECT \"MigrationId\" FROM public.\"__EFMigrationsHistory\";"
20260905192825_InitialSystem # ровно одна строка 20260905192825_InitialSystem # ровно одна строка
$ \d public.users $ \d public.users
Id | uuid | not null Id | uuid | not null
Login | character varying(200) | not null Login | character varying(200) | not null
TenantId | uuid | not null TenantId | uuid | not null
PasswordHash | text | not null PasswordHash | text | not null
Status | text | not null | 'active'::text Status | text | not null | 'active'::text
CreatedAt | timestamp with time zone | not null | now() CreatedAt | timestamp with time zone | not null | now()
Indexes: PK_users (Id); IX_users_Login UNIQUE (Login); IX_users_TenantId (TenantId) Indexes: PK_users (Id); IX_users_Login UNIQUE (Login); IX_users_TenantId (TenantId)
FK: FK_users_tenants_TenantId → tenants(Id) ON DELETE RESTRICT FK: FK_users_tenants_TenantId → tenants(Id) ON DELETE RESTRICT
(Referenced by) FK_sessions_users_UserId → users(Id) ON DELETE CASCADE (Referenced by) FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
$ \d public.sessions $ \d public.sessions
TokenHash | character varying(64) | not null TokenHash | character varying(64) | not null
UserId | uuid | not null UserId | uuid | not null
Login | character varying(200) | not null Login | character varying(200) | not null
ExpiresAt | timestamp with time zone | not null ExpiresAt | timestamp with time zone | not null
CreatedAt | timestamp with time zone | not null CreatedAt | timestamp with time zone | not null
Indexes: PK_sessions (TokenHash); IX_sessions_ExpiresAt (ExpiresAt); IX_sessions_UserId (UserId) Indexes: PK_sessions (TokenHash); IX_sessions_ExpiresAt (ExpiresAt); IX_sessions_UserId (UserId)
FK: FK_sessions_users_UserId → users(Id) ON DELETE CASCADE FK: FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
``` ```
Колонки/индексы соответствуют конфигурациям. `public.tenants` — без изменений по сравнению со Колонки/индексы соответствуют конфигурациям. `public.tenants` — без изменений по сравнению со
скэффолдом (та же схема, что была у `InitialPublic`). скэффолдом (та же схема, что была у `InitialPublic`).
### Стиль ### Стиль
1 тип = 1 файл; XML-doc на public-типах (и на неочевидных свойствах: Login у сессии, 1 тип = 1 файл; XML-doc на public-типах (и на неочевидных свойствах: Login у сессии,
TokenHash и т.п.); комментарии на русском; явные модификаторы; регионов нет; именованные TokenHash и т.п.); комментарии на русском; явные модификаторы; регионов нет; именованные
константы вместо «магических» длин (`LoginMaxLength = 200`, `TokenHashMaxLength = 64`, константы вместо «магических» длин (`LoginMaxLength = 200`, `TokenHashMaxLength = 64`,
`KeyMaxLength = 200`, `NameMaxLength = 200`). `KeyMaxLength = 200`, `NameMaxLength = 200`).
## Отклонения и решения ## Отклонения и решения
1. **Расположение tenant-миграции (основное).** `dotnet ef migrations add InitialTenant` 1. **Расположение tenant-миграции (основное).** `dotnet ef migrations add InitialTenant`
(без `--output-dir`, как требует план) НЕ положил файлы в общую папку `Migrations/`, (без `--output-dir`, как требует план) НЕ положил файлы в общую папку `Migrations/`,
а молча создал подпапку `Migrations/TenantDb/` с namespace `Deal.Infrastructure.Migrations.TenantDb`. а молча создал подпапку `Migrations/TenantDb/` с namespace `Deal.Infrastructure.Migrations.TenantDb`.
Поведение детерминированное (проверено: удалил папку и повторил команду — результат тот же, Поведение детерминированное (проверено: удалил папку и повторил команду — результат тот же,
новый timestamp `20260905193010`). Ошибки или запроса `--output-dir` не было; имена классов новый timestamp `20260905193010`). Ошибки или запроса `--output-dir` не было; имена классов
(`InitialTenant`, `TenantDbContextModelSnapshot`) действительно не конфликтуют, но инструмент (`InitialTenant`, `TenantDbContextModelSnapshot`) действительно не конфликтуют, но инструмент
всё равно изолирует второй контекст в подпапку. Функционально ни на что не влияет: EF выбирает всё равно изолирует второй контекст в подпапку. Функционально ни на что не влияет: EF выбирает
миграции контекста по атрибуту `[DbContext(...)]` в assembly, а не по namespace; `Database.Migrate()` миграции контекста по атрибуту `[DbContext(...)]` в assembly, а не по namespace; `Database.Migrate()`
в Task 5 найдёт `InitialTenant` и создаст таблицу истории в схеме тенанта. По инструкции задачи в Task 5 найдёт `InitialTenant` и создаст таблицу истории в схеме тенанта. По инструкции задачи
(«не придумывайте обходы сами») файлы НЕ переносил и namespace вручную не правил. Если ревьюеру («не придумывайте обходы сами») файлы НЕ переносил и namespace вручную не правил. Если ревьюеру
критично именно расположение в корне `Migrations/` — можно пересоздать через критично именно расположение в корне `Migrations/` — можно пересоздать через
`--output-dir Migrations`, но я осознанно оставил детерминированный вывод инструмента. `--output-dir Migrations`, но я осознанно оставил детерминированный вывод инструмента.
2. **Ожидание `CREATE TABLE "__TenantMigrationsHistory"` в теле миграции.** План (проверка 5) 2. **Ожидание `CREATE TABLE "__TenantMigrationsHistory"` в теле миграции.** План (проверка 5)
предполагал в `InitialTenant` два `CreateTable`: `settings` и историю. По факту тело миграции предполагал в `InitialTenant` два `CreateTable`: `settings` и историю. По факту тело миграции
содержит только `CREATE TABLE "settings"` — таблица истории миграций в EF создаётся содержит только `CREATE TABLE "settings"` — таблица истории миграций в EF создаётся
инфраструктурой при применении (`Migrate`/`database update`), а не телом миграции. инфраструктурой при применении (`Migrate`/`database update`), а не телом миграции.
Требуемое «нет упоминаний схемы» выполнено (0 совпадений). Применение tenant-миграции Требуемое «нет упоминаний схемы» выполнено (0 совпадений). Применение tenant-миграции
не выполнялось (по плану это Task 5), поэтому фактическое создание `__TenantMigrationsHistory` не выполнялось (по плану это Task 5), поэтому фактическое создание `__TenantMigrationsHistory`
в схеме тенанта будет проверено в Task 5 (см. `progress.md`, строка T2→T5). в схеме тенанта будет проверено в Task 5 (см. `progress.md`, строка T2→T5).
3. **`migrations list` без `--context`.** После появления второго DbContext команда без `--context` 3. **`migrations list` без `--context`.** После появления второго DbContext команда без `--context`
падает с «More than one DbContext was found...». Обе миграции видны при запуске по контекстам падает с «More than one DbContext was found...». Обе миграции видны при запуске по контекстам
(см. выше) — это и есть содержимое Acceptance 6; в отчёте зафиксирован требуемый флаг. (см. выше) — это и есть содержимое Acceptance 6; в отчёте зафиксирован требуемый флаг.
4. **Колонки/типы `settings`.** Задача (код-уровень) задаёт `Key` с MaxLength 200 и `ValueJson` как 4. **Колонки/типы `settings`.** Задача (код-уровень) задаёт `Key` с MaxLength 200 и `ValueJson` как
`text`; Ruling 9 в SQL-нотации описывает `key text PK`, `value_json`, `updated_at`. Реализовано `text`; Ruling 9 в SQL-нотации описывает `key text PK`, `value_json`, `updated_at`. Реализовано
по кодовой спецификации задачи и в конвенции кодовой базы этапа (колонки PascalCase — как у по кодовой спецификации задачи и в конвенции кодовой базы этапа (колонки PascalCase — как у
существующей `public.tenants` из скэффолда, без naming-convention пакета): колонки существующей `public.tenants` из скэффолда, без naming-convention пакета): колонки
`Key` varchar(200) PK, `ValueJson` text NOT NULL, `UpdatedAt` timestamptz NOT NULL. `Key` varchar(200) PK, `ValueJson` text NOT NULL, `UpdatedAt` timestamptz NOT NULL.
Ровно те же соглашения применены к `users`/`sessions` (колонки = имена свойств). Ровно те же соглашения применены к `users`/`sessions` (колонки = имена свойств).
5. **Семантика FK.** В конфигурациях созданы реальные внешние ключи (в задаче они заявлены как 5. **Семантика FK.** В конфигурациях созданы реальные внешние ключи (в задаче они заявлены как
«FK → ...»): `users.TenantId → tenants.Id` ON DELETE RESTRICT (защита от случайного каскадного «FK → ...»): `users.TenantId → tenants.Id` ON DELETE RESTRICT (защита от случайного каскадного
сноса пользователей при удалении тенанта; удаления тенантов в этапе нет), `sessions.UserId → сноса пользователей при удалении тенанта; удаления тенантов в этапе нет), `sessions.UserId →
users.Id` ON DELETE CASCADE (сессии — транзитивные данные пользователя). Поведение в этапе 1 users.Id` ON DELETE CASCADE (сессии — транзитивные данные пользователя). Поведение в этапе 1
ничем не упражняется — зафиксировано для Task 3+. ничем не упражняется — зафиксировано для Task 3+.
## Прочее ## Прочее
- `TenantDbContext` в DI не регистрировался (только класс + design-time фабрика) — по задаче. - `TenantDbContext` в DI не регистрировался (только класс + design-time фабрика) — по задаче.
- `Deal.Api/Program.cs` не менялся; для сборки дополнительные `using` не понадобились. - `Deal.Api/Program.cs` не менялся; для сборки дополнительные `using` не понадобились.
- `DealDbDesignTimeFactory.cs` — без изменений (уже соответствует требованию «как сейчас»). - `DealDbDesignTimeFactory.cs` — без изменений (уже соответствует требованию «как сейчас»).
- Другие модули/фронтенд/`backend` не тронуты; `.editorconfig`/`Directory.Build.props` не менялись. - Другие модули/фронтенд/`backend` не тронуты; `.editorconfig`/`Directory.Build.props` не менялись.
@@ -1,142 +1,142 @@
# Task 3 — Модуль Tenants: прикладные сервисы аутентификации и тенантов. Отчёт # Task 3 — Модуль Tenants: прикладные сервисы аутентификации и тенантов. Отчёт
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`. Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS (6 старых + 19 новых, требуется ≥6). Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS (6 старых + 19 новых, требуется ≥6).
## Итог ## Итог
Реализован модуль `Deal.Modules.Tenants` по паттерну «port & adapter» (Ruling 1): прикладные Реализован модуль `Deal.Modules.Tenants` по паттерну «port & adapter» (Ruling 1): прикладные
сервисы аутентификации (`AuthService`) и реестра тенантов (`TenantService`), порты сервисы аутентификации (`AuthService`) и реестра тенантов (`TenantService`), порты
`IPasswordHasher`/`IAuthStore`/`ITenantRepository`/`ITenantProvisioner`, record-DTO `IPasswordHasher`/`IAuthStore`/`ITenantRepository`/`ITenantProvisioner`, record-DTO
(`Application/Models`). Модуль НЕ содержит EF и НЕ ссылается на Infrastructure (проверено grep). (`Application/Models`). Модуль НЕ содержит EF и НЕ ссылается на Infrastructure (проверено grep).
EF-адаптеры (`AuthStore`, `TenantRepository`) добавлены в `Deal.Infrastructure` EF-адаптеры (`AuthStore`, `TenantRepository`) добавлены в `Deal.Infrastructure`
(+ ProjectReference на модуль, циклов нет). Хэш пароля — Argon2id пакетом (+ ProjectReference на модуль, циклов нет). Хэш пароля — Argon2id пакетом
`Isopoh.Cryptography.Argon2` 2.0.0 через `Argon2.Hash/Verify` (Ruling 5); токен сессии — `Isopoh.Cryptography.Argon2` 2.0.0 через `Argon2.Hash/Verify` (Ruling 5); токен сессии —
32 байта Base64Url, в БД — SHA-256 hex (Ruling 6); сессия 30 дней; смена пароля инвалидирует 32 байта Base64Url, в БД — SHA-256 hex (Ruling 6); сессия 30 дней; смена пароля инвалидирует
все сессии и выдаёт свежую (семантика `backend/app/auth.py` + `auth_routes.change`). все сессии и выдаёт свежую (семантика `backend/app/auth.py` + `auth_routes.change`).
## Файлы ## Файлы
### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`) ### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`)
- `IPasswordHasher.cs` — порт: `Hash(password)` → encoded-строка; `Verify(password, encoded)`. - `IPasswordHasher.cs` — порт: `Hash(password)` → encoded-строка; `Verify(password, encoded)`.
- `DefaultPasswordHasher.cs` — Argon2id (Ruling 5). Вызовы `Argon2.Hash(password)` / - `DefaultPasswordHasher.cs` — Argon2id (Ruling 5). Вызовы `Argon2.Hash(password)` /
`Argon2.Verify(encoded, password)` — это дефолты библиотеки 2.0.0: Argon2id `Argon2.Verify(encoded, password)` — это дефолты библиотеки 2.0.0: Argon2id
(`Argon2Type.HybridAddressing` — подтверждено исходником v2.0.0), соль 16 случайных байт, (`Argon2Type.HybridAddressing` — подтверждено исходником v2.0.0), соль 16 случайных байт,
t=3, m=65536 (64 MiB), p=1, длина хэша 32 байта. Комментарий про дефолты — в XML-doc. t=3, m=65536 (64 MiB), p=1, длина хэша 32 байта. Комментарий про дефолты — в XML-doc.
- `SessionTokens.cs``NewToken()` (32 байта `RandomNumberGenerator` → Base64Url без padding, - `SessionTokens.cs``NewToken()` (32 байта `RandomNumberGenerator` → Base64Url без padding,
43 символа) и `HashToken(raw)` (SHA-256 hex, 64 символа). 43 символа) и `HashToken(raw)` (SHA-256 hex, 64 символа).
- `Application/Models/StoredUserDto.cs`, `UserIdentityDto.cs`, `SessionDto.cs`, - `Application/Models/StoredUserDto.cs`, `UserIdentityDto.cs`, `SessionDto.cs`,
`TenantRecordDto.cs`, `LoginResultDto.cs`, `ChangePasswordResultDto.cs` — record, 1 тип = 1 файл; `TenantRecordDto.cs`, `LoginResultDto.cs`, `ChangePasswordResultDto.cs` — record, 1 тип = 1 файл;
коды ошибок смены пароля — public-константы на `ChangePasswordResultDto` коды ошибок смены пароля — public-константы на `ChangePasswordResultDto`
(`ErrorOldPassword = "oldPassword"`, `ErrorTooShort = "tooShort"`). (`ErrorOldPassword = "oldPassword"`, `ErrorTooShort = "tooShort"`).
- `IAuthStore.cs` — порт хранилища: 8 async-методов с `CancellationToken` (поиск по логину/токену/id, - `IAuthStore.cs` — порт хранилища: 8 async-методов с `CancellationToken` (поиск по логину/токену/id,
create/delete сессий, update хэша, очистка протухших). Модификаторы интерфейса явные (`public`), create/delete сессий, update хэша, очистка протухших). Модификаторы интерфейса явные (`public`),
как требует `.editorconfig` (IDE0040) и существующий `ITenantContext`. как требует `.editorconfig` (IDE0040) и существующий `ITenantContext`.
- `AuthService.cs` — login/logout/changePassword/resolveSession + private helper - `AuthService.cs` — login/logout/changePassword/resolveSession + private helper
`CreateSessionForUserAsync`. Логин нормализуется `ToLowerInvariant().Trim()`. Бизнес-отказы — `CreateSessionForUserAsync`. Логин нормализуется `ToLowerInvariant().Trim()`. Бизнес-отказы —
null/коды в DTO, исключений не бросает. `SessionLifetimeDays = 30`, `MinNewPasswordLength = 4`. null/коды в DTO, исключений не бросает. `SessionLifetimeDays = 30`, `MinNewPasswordLength = 4`.
resolveSession проверяет `ExpiresAt > UtcNow` и всегда вызывает `DeleteExpiredSessionsAsync`. resolveSession проверяет `ExpiresAt > UtcNow` и всегда вызывает `DeleteExpiredSessionsAsync`.
- `ITenantRepository.cs``FindByIdAsync/CreateAsync/ListAsync`. - `ITenantRepository.cs``FindByIdAsync/CreateAsync/ListAsync`.
- `ITenantProvisioner.cs``ProvisionAsync(TenantId, ct)` (реализация — Task 5). - `ITenantProvisioner.cs``ProvisionAsync(TenantId, ct)` (реализация — Task 5).
- `TenantService.cs``CreateTenantAsync` (Guid `"N"`, status "active", CreatedAt=UtcNow → - `TenantService.cs``CreateTenantAsync` (Guid `"N"`, status "active", CreatedAt=UtcNow →
репозиторий → `ITenantProvisioner.ProvisionAsync`) и `ListTenantsAsync`. репозиторий → `ITenantProvisioner.ProvisionAsync`) и `ListTenantsAsync`.
- `TenantModuleRegistrar.cs``AddTenantsModule(this IServiceCollection)`: singleton - `TenantModuleRegistrar.cs``AddTenantsModule(this IServiceCollection)`: singleton
`IPasswordHasher → DefaultPasswordHasher`, scoped `AuthService`/`TenantService`; адаптеры `IPasswordHasher → DefaultPasswordHasher`, scoped `AuthService`/`TenantService`; адаптеры
`IAuthStore`/`ITenantRepository`/`ITenantProvisioner` НЕ регистрируются (комментарий-обоснование `IAuthStore`/`ITenantRepository`/`ITenantProvisioner` НЕ регистрируются (комментарий-обоснование
времён жизни в XML-doc). времён жизни в XML-doc).
### Созданы — `src/core/Deal.Infrastructure/Persistence/Repositories/` ### Созданы — `src/core/Deal.Infrastructure/Persistence/Repositories/`
- `AuthStore.cs` — EF-адаптер `IAuthStore` на `DealDbContext` (public.users/sessions): - `AuthStore.cs` — EF-адаптер `IAuthStore` на `DealDbContext` (public.users/sessions):
`FindSessionByTokenHashAsync` учитывает `ExpiresAt > UtcNow`; delete/update — через `FindSessionByTokenHashAsync` учитывает `ExpiresAt > UtcNow`; delete/update — через
`ExecuteDeleteAsync`/`ExecuteUpdateAsync` (без трекинга); `DeleteExpiredSessionsAsync` `ExecuteDeleteAsync`/`ExecuteUpdateAsync` (без трекинга); `DeleteExpiredSessionsAsync`
удаляет `ExpiresAt <= UtcNow`. Маппинг DTO↔сущности вручную. удаляет `ExpiresAt <= UtcNow`. Маппинг DTO↔сущности вручную.
- `TenantRepository.cs` — EF-адаптер `ITenantRepository` (public.tenants), список упорядочен - `TenantRepository.cs` — EF-адаптер `ITenantRepository` (public.tenants), список упорядочен
по `CreatedAt`. по `CreatedAt`.
### Изменены ### Изменены
- `src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj` — добавлены PackageReference: - `src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj` — добавлены PackageReference:
`Isopoh.Cryptography.Argon2` 2.0.0, `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11. `Isopoh.Cryptography.Argon2` 2.0.0, `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11.
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на - `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
`..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj` (циклов нет). `..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj` (циклов нет).
### Созданы — `src/core/tests/Deal.Tests.Unit/` ### Созданы — `src/core/tests/Deal.Tests.Unit/`
- `PasswordHasherTests.cs` (4 теста), `SessionTokensTests.cs` (4), `AuthServiceTests.cs` (11), - `PasswordHasherTests.cs` (4 теста), `SessionTokensTests.cs` (4), `AuthServiceTests.cs` (11),
fakes: `FakeAuthStore.cs`, `FakePasswordHasher.cs` (по 1 типу в файле). Fake-хранилище fakes: `FakeAuthStore.cs`, `FakePasswordHasher.cs` (по 1 типу в файле). Fake-хранилище
НЕ фильтрует протухшие сессии при поиске — так проверяется, что сервис сам учитывает `ExpiresAt`. НЕ фильтрует протухшие сессии при поиске — так проверяется, что сервис сам учитывает `ExpiresAt`.
## Команды и вывод ## Команды и вывод
``` ```
$ dotnet build Deal.sln --nologo $ dotnet build Deal.sln --nologo
Сборка успешно выполнено через 2,9 с # 0 warnings / 0 errors (TreatWarningsAsErrors) Сборка успешно выполнено через 2,9 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
$ dotnet test tests/Deal.Tests.Unit --nologo --no-build $ dotnet test tests/Deal.Tests.Unit --nologo --no-build
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
``` ```
Grep-проверка изоляции модуля (по `src/core/Deal.Modules.Tenants/`): Grep-проверка изоляции модуля (по `src/core/Deal.Modules.Tenants/`):
`Deal.Infrastructure|Microsoft.EntityFrameworkCore|Npgsql` → 0 совпадений; `Deal.Infrastructure|Microsoft.EntityFrameworkCore|Npgsql` → 0 совпадений;
`using Deal.Infrastructure|using Microsoft.EntityFrameworkCore|using Npgsql` → 0 совпадений. `using Deal.Infrastructure|using Microsoft.EntityFrameworkCore|using Npgsql` → 0 совпадений.
## Список тестов (новые, 19) ## Список тестов (новые, 19)
PasswordHasherTests: PasswordHasherTests:
1. `Hash_ReturnsArgon2idEncodedStringDifferentFromPassword` — encoded ≠ пароль, префикс `$argon2id$`. 1. `Hash_ReturnsArgon2idEncodedStringDifferentFromPassword` — encoded ≠ пароль, префикс `$argon2id$`.
2. `Verify_WithCorrectPassword_ReturnsTrue`. 2. `Verify_WithCorrectPassword_ReturnsTrue`.
3. `Verify_WithWrongPassword_ReturnsFalse`. 3. `Verify_WithWrongPassword_ReturnsFalse`.
4. `Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt`. 4. `Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt`.
SessionTokensTests: SessionTokensTests:
5. `NewToken_ReturnsUniqueLongEnoughTokens`. 5. `NewToken_ReturnsUniqueLongEnoughTokens`.
6. `NewToken_UsesBase64UrlAlphabetWithoutPadding` — 43 символа, без `= + /`. 6. `NewToken_UsesBase64UrlAlphabetWithoutPadding` — 43 символа, без `= + /`.
7. `HashToken_IsDeterministicHexSha256` — 64 hex, детерминирован. 7. `HashToken_IsDeterministicHexSha256` — 64 hex, детерминирован.
8. `HashToken_DifferentTokensProduceDifferentHashes`. 8. `HashToken_DifferentTokensProduceDifferentHashes`.
AuthServiceTests (fake IAuthStore + fake хэшер): AuthServiceTests (fake IAuthStore + fake хэшер):
9. `LoginAsync_WithValidCredentials_ReturnsTokenAndCreatesSession` — логин нормализуется 9. `LoginAsync_WithValidCredentials_ReturnsTokenAndCreatesSession` — логин нормализуется
(" Admin " → "admin"), сессия создана с SHA-256-хэшем токена и ExpiresAt в будущем. (" Admin " → "admin"), сессия создана с SHA-256-хэшем токена и ExpiresAt в будущем.
10. `LoginAsync_WithWrongPassword_ReturnsNullLoginAndToken`. 10. `LoginAsync_WithWrongPassword_ReturnsNullLoginAndToken`.
11. `LoginAsync_WithUnknownLogin_ReturnsNullLoginAndToken`. 11. `LoginAsync_WithUnknownLogin_ReturnsNullLoginAndToken`.
12. `ChangePasswordAsync_WithWrongOldPassword_ReturnsOldPasswordError``"oldPassword"`, вызовов нет. 12. `ChangePasswordAsync_WithWrongOldPassword_ReturnsOldPasswordError``"oldPassword"`, вызовов нет.
13. `ChangePasswordAsync_WithShortNewPassword_ReturnsTooShortError``"tooShort"` (новый пароль "123"). 13. `ChangePasswordAsync_WithShortNewPassword_ReturnsTooShortError``"tooShort"` (новый пароль "123").
14. `ChangePasswordAsync_Success_InvalidatesOldSessionsAndCreatesFreshOne` — журнал вызовов 14. `ChangePasswordAsync_Success_InvalidatesOldSessionsAndCreatesFreshOne` — журнал вызовов
ровно `delete-user-sessions → update-password-hash → create-session`; в хранилище одна новая ровно `delete-user-sessions → update-password-hash → create-session`; в хранилище одна новая
сессия; старый raw-токен не резолвится, новый — резолвится на того же пользователя. сессия; старый raw-токен не резолвится, новый — резолвится на того же пользователя.
15. `ResolveSessionAsync_WithExpiredSession_ReturnsNull` — протухшая сессия (fake вернул её) → null; 15. `ResolveSessionAsync_WithExpiredSession_ReturnsNull` — протухшая сессия (fake вернул её) → null;
`DeleteExpiredSessionsAsync` вызвана, сессия удалена. `DeleteExpiredSessionsAsync` вызвана, сессия удалена.
16. `ResolveSessionAsync_WithValidSession_ReturnsUser`. 16. `ResolveSessionAsync_WithValidSession_ReturnsUser`.
17. `ResolveSessionAsync_WithoutToken_ReturnsNull` (null и пробелы, вызовов нет). 17. `ResolveSessionAsync_WithoutToken_ReturnsNull` (null и пробелы, вызовов нет).
18. `LogoutAsync_WithToken_DeletesSession`. 18. `LogoutAsync_WithToken_DeletesSession`.
19. `LogoutAsync_WithoutToken_IsNoOp`. 19. `LogoutAsync_WithoutToken_IsNoOp`.
Старые 6 тестов (Marker/TenantId/TenantEntity/TenantSchemaMigrator) — без изменений, PASS. Старые 6 тестов (Marker/TenantId/TenantEntity/TenantSchemaMigrator) — без изменений, PASS.
## Проверки (Acceptance) ## Проверки (Acceptance)
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅ 1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS (6 старых + 19 новых, ≥6). ✅ 2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS (6 старых + 19 новых, ≥6). ✅
3. Стиль: 1 тип = 1 файл; XML-doc на public-типах и членах интерфейсов; явные модификаторы 3. Стиль: 1 тип = 1 файл; XML-doc на public-типах и членах интерфейсов; явные модификаторы
(в т.ч. `public` у членов интерфейсов — IDE0040); без магических чисел (именованные константы (в т.ч. `public` у членов интерфейсов — IDE0040); без магических чисел (именованные константы
`SessionLifetimeDays`, `MinNewPasswordLength`, `RawTokenByteLength`, `ActiveStatus`, коды ошибок); `SessionLifetimeDays`, `MinNewPasswordLength`, `RawTokenByteLength`, `ActiveStatus`, коды ошибок);
комментарии на русском. ✅ комментарии на русском. ✅
4. В модуле НЕТ using/ссылок на Deal.Infrastructure и EF — подтверждено grep. ✅ 4. В модуле НЕТ using/ссылок на Deal.Infrastructure и EF — подтверждено grep. ✅
## Отклонения и решения ## Отклонения и решения
1. **Версия Isopoh зафиксирована 2.0.0** (latest stable, поставлена `dotnet add package`). API 1. **Версия Isopoh зафиксирована 2.0.0** (latest stable, поставлена `dotnet add package`). API
подтверждён по исходникам тега v2.0.0 и README пакета: требуемые задачей вызовы подтверждён по исходникам тега v2.0.0 и README пакета: требуемые задачей вызовы
`Argon2.Hash(password)` / `Argon2.Verify(encoded, password)` существуют; по умолчанию вариант `Argon2.Hash(password)` / `Argon2.Verify(encoded, password)` существуют; по умолчанию вариант
`HybridAddressing` (Argon2id, префикс encoded-строки `$argon2id$`), соль 16 байт, t=3, m=64 MiB, `HybridAddressing` (Argon2id, префикс encoded-строки `$argon2id$`), соль 16 байт, t=3, m=64 MiB,
p=1. Требование «Argon2id, дефолты библиотеки» выполнено ровно так, как описано в задаче. p=1. Требование «Argon2id, дефолты библиотеки» выполнено ровно так, как описано в задаче.
2. **DI-регистрация адаптеров** (`IAuthStore`, `ITenantRepository`, `ITenantProvisioner`) в Task 3 2. **DI-регистрация адаптеров** (`IAuthStore`, `ITenantRepository`, `ITenantProvisioner`) в Task 3
НЕ выполнялась: инструкция задачи (п. «Ключевые решения», файл 11–12) явно откладывает её в НЕ выполнялась: инструкция задачи (п. «Ключевые решения», файл 11–12) явно откладывает её в
Deal.Api (Task 4/5), тогда как общая строка плана [L85] упоминала `ServiceCollectionExtensions.cs` Deal.Api (Task 4/5), тогда как общая строка плана [L85] упоминала `ServiceCollectionExtensions.cs`
в Infrastructure (файла в проекте нет). Следовал детальной спецификации задачи — регистрация в Infrastructure (файла в проекте нет). Следовал детальной спецификации задачи — регистрация
адаптеров будет в `Deal.Api/Program.cs` в Task 4. адаптеров будет в `Deal.Api/Program.cs` в Task 4.
3. **`TenantModuleRegistrar`** расположен в `Application/` (namespace `Deal.Modules.Tenants.Application`) 3. **`TenantModuleRegistrar`** расположен в `Application/` (namespace `Deal.Modules.Tenants.Application`)
— по списку файлов задачи (п. 11); в Task 4 достаточно `using Deal.Modules.Tenants.Application`. — по списку файлов задачи (п. 11); в Task 4 достаточно `using Deal.Modules.Tenants.Application`.
4. **`FindSessionByTokenHashAsync`** (adapter) дополнительно фильтрует по `ExpiresAt` (требование 4. **`FindSessionByTokenHashAsync`** (adapter) дополнительно фильтрует по `ExpiresAt` (требование
п. 12), а `AuthService.ResolveSessionAsync` независимо проверяет `ExpiresAt > now` (требование п. 12), а `AuthService.ResolveSessionAsync` независимо проверяет `ExpiresAt > now` (требование
п. 6) — проверка в сервисе необходима для корректной работы с любым хранилищем и покрыта тестом 15. п. 6) — проверка в сервисе необходима для корректной работы с любым хранилищем и покрыта тестом 15.
5. Стоимость Argon2 по дефолту библиотеки — 64 MiB памяти на хэш; тестовый набор целиком 5. Стоимость Argon2 по дефолту библиотеки — 64 MiB памяти на хэш; тестовый набор целиком
укладывается в ~5 с, ничего не переопределял (по заданию — дефолты). укладывается в ~5 с, ничего не переопределял (по заданию — дефолты).
@@ -1,96 +1,96 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 4 curl-приёмка auth-эндпоинтов Deal.Api на :5080 (см. план Task 4, п.7). # Task 4 curl-приёмка auth-эндпоинтов Deal.Api на :5080 (см. план Task 4, п.7).
# Сценарий: health → login admin/admin (кука в jar-old) → me → неверный пароль (401) → # Сценарий: health → login admin/admin (кука в jar-old) → me → неверный пароль (401) →
# change-password (admin→admin2, новая кука в jar-new) → logout старой сессии → проверки # change-password (admin→admin2, новая кука в jar-new) → logout старой сессии → проверки
# me/logins → возврат пароля admin. Вывод каждой команды печатается в stdout. # me/logins → возврат пароля admin. Вывод каждой команды печатается в stdout.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
CORE_DIR="C:/telbase/src/core" CORE_DIR="C:/telbase/src/core"
API_DIR="$CORE_DIR/Deal.Api" API_DIR="$CORE_DIR/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR_OLD="/tmp/task4-jar-old.txt" JAR_OLD="/tmp/task4-jar-old.txt"
JAR_NEW="/tmp/task4-jar-new.txt" JAR_NEW="/tmp/task4-jar-new.txt"
rm -f "$JAR_OLD" "$JAR_NEW" /tmp/task4-api.log rm -f "$JAR_OLD" "$JAR_NEW" /tmp/task4-api.log
echo "== 0. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > /tmp/task4-api.log 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > /tmp/task4-api.log 2>&1 &
APP_PID=$! APP_PID=$!
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем сервер (pid $APP_PID) ==" echo "== Завершение: останавливаем сервер (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
sleep 10 sleep 10
echo echo
echo "== 1. GET /api/health ==" echo "== 1. GET /api/health =="
curl -s "$BASE_URL/api/health" curl -s "$BASE_URL/api/health"
echo echo
echo echo
echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok,login} + Set-Cookie (httpOnly) ==" echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok,login} + Set-Cookie (httpOnly) =="
curl -s -i -c "$JAR_OLD" -X POST "$BASE_URL/api/auth/login" \ curl -s -i -c "$JAR_OLD" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
echo echo
echo echo
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} ==" echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me" curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me"
echo echo
echo echo
echo "== 4. POST /api/auth/login {admin,wrong} — ожидаем 401 {detail} ==" echo "== 4. POST /api/auth/login {admin,wrong} — ожидаем 401 {detail} =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"wrong"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"wrong"}'
echo echo
echo echo
echo "== 5. POST /api/auth/change-password {admin → admin2} (кука jar-old) — ожидаем 200 {ok} + новая кука в jar-new ==" echo "== 5. POST /api/auth/change-password {admin → admin2} (кука jar-old) — ожидаем 200 {ok} + новая кука в jar-new =="
curl -s -i -c "$JAR_NEW" -b "$JAR_OLD" -X POST "$BASE_URL/api/auth/change-password" \ curl -s -i -c "$JAR_NEW" -b "$JAR_OLD" -X POST "$BASE_URL/api/auth/change-password" \
-H "Content-Type: application/json" -d '{"oldPassword":"admin","newPassword":"admin2"}' -H "Content-Type: application/json" -d '{"oldPassword":"admin","newPassword":"admin2"}'
echo echo
echo echo
echo "== 6. POST /api/auth/logout старой сессией (jar-old) — ожидаем 200 {ok} ==" echo "== 6. POST /api/auth/logout старой сессией (jar-old) — ожидаем 200 {ok} =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR_OLD" curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR_OLD"
echo echo
echo echo
echo "== 7. GET /api/auth/me с jar-old — ожидаем 401 {detail: Требуется авторизация} ==" echo "== 7. GET /api/auth/me с jar-old — ожидаем 401 {detail: Требуется авторизация} =="
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me" curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me"
echo echo
echo echo
echo "== 8. GET /api/auth/me с jar-new — ожидаем 200 {login,ok} ==" echo "== 8. GET /api/auth/me с jar-new — ожидаем 200 {login,ok} =="
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me" curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me"
echo echo
echo echo
echo "== 9. login admin/admin — ожидаем 401 ==" echo "== 9. login admin/admin — ожидаем 401 =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
echo echo
echo echo
echo "== 10. login admin/admin2 — ожидаем 200 {ok,login} (кука в jar-old) ==" echo "== 10. login admin/admin2 — ожидаем 200 {ok,login} (кука в jar-old) =="
curl -s -c "$JAR_OLD" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -c "$JAR_OLD" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin2"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin2"}'
echo echo
echo echo
echo "== 11. Возврат пароля: change-password {admin2 → admin} (кука jar-new) — ожидаем 200 {ok} ==" echo "== 11. Возврат пароля: change-password {admin2 → admin} (кука jar-new) — ожидаем 200 {ok} =="
curl -s -i -c "$JAR_NEW" -b "$JAR_NEW" -X POST "$BASE_URL/api/auth/change-password" \ curl -s -i -c "$JAR_NEW" -b "$JAR_NEW" -X POST "$BASE_URL/api/auth/change-password" \
-H "Content-Type: application/json" -d '{"oldPassword":"admin2","newPassword":"admin"}' -H "Content-Type: application/json" -d '{"oldPassword":"admin2","newPassword":"admin"}'
echo echo
echo echo
echo "== 12. Финальная проверка: me с jar-new — ожидаем 200 {login,ok} ==" echo "== 12. Финальная проверка: me с jar-new — ожидаем 200 {login,ok} =="
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me" curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me"
echo echo
@@ -1,132 +1,132 @@
# Task 4 — Эндпоинты auth, middleware сессии, DI, curl-приёмка. Отчёт # Task 4 — Эндпоинты auth, middleware сессии, DI, curl-приёмка. Отчёт
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md` (Task 4, Rulings 6/7/7a/8/10). Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md` (Task 4, Rulings 6/7/7a/8/10).
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; curl-приёмка на :5080 проходит целиком (два прогона подряд — второй подтверждает идемпотентность seed). Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; curl-приёмка на :5080 проходит целиком (два прогона подряд — второй подтверждает идемпотентность seed).
## Итог ## Итог
`Deal.Api` получил полный HTTP-контракт auth 1:1 с прототипом (`auth_routes.py`): login/logout/me/change-password `Deal.Api` получил полный HTTP-контракт auth 1:1 с прототипом (`auth_routes.py`): login/logout/me/change-password
на группе `/api/auth`, httpOnly-кука `deal_session` (Ruling 6), пайплайн `CORS → SessionMiddleware → эндпоинты`, на группе `/api/auth`, httpOnly-кука `deal_session` (Ruling 6), пайплайн `CORS → SessionMiddleware → эндпоинты`,
DI модуля и адаптеров, минимальный seed (Ruling 8). `SessionMiddleware` разрешает сессию по куке, кладёт DI модуля и адаптеров, минимальный seed (Ruling 8). `SessionMiddleware` разрешает сессию по куке, кладёт
`CurrentUser` в `HttpContext.Items` и выставляет tenant-контекст запроса; сама 401 не отвечает (pass-through). `CurrentUser` в `HttpContext.Items` и выставляет tenant-контекст запроса; сама 401 не отвечает (pass-through).
В `ITenantContext` добавлены `SetTenant`/`Reset` (сброс в finally после запроса). В `ITenantContext` добавлены `SetTenant`/`Reset` (сброс в finally после запроса).
## Файлы ## Файлы
### Созданы — `src/core/Deal.Api/` ### Созданы — `src/core/Deal.Api/`
- `Configuration/CookieOptions.cs` — настройки куки (секция `Cookies`): `Name="deal_session"`, `Days`, `Secure`. - `Configuration/CookieOptions.cs` — настройки куки (секция `Cookies`): `Name="deal_session"`, `Days`, `Secure`.
`Days` намеренно без код-дефолта: единственный источник «30» — конфигурация (см. XML-doc; константа `Days` намеренно без код-дефолта: единственный источник «30» — конфигурация (см. XML-doc; константа
`AuthService.SessionLifetimeDays` переедет в конфиг в Task 5). `AuthService.SessionLifetimeDays` переедет в конфиг в Task 5).
- `Http/CurrentUser.cs``sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status)`. - `Http/CurrentUser.cs``sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status)`.
- `Http/AuthHelpers.cs` — ключ `HttpContext.Items["CurrentUser"]` (public const), `SetCurrentUser`, - `Http/AuthHelpers.cs` — ключ `HttpContext.Items["CurrentUser"]` (public const), `SetCurrentUser`,
`GetCurrentUser` (+ const сообщения 401). Сделано прагматично: эндпоинты сами проверяют null и отдают 401 `GetCurrentUser` (+ const сообщения 401). Сделано прагматично: эндпоинты сами проверяют null и отдают 401
(tuple-хелпер `RequireUser` из формулировки задачи не понадобился — точек использования всего две). (tuple-хелпер `RequireUser` из формулировки задачи не понадобился — точек использования всего две).
- `Middleware/SessionMiddleware.cs` — singleton; опции через `IOptionsMonitor<CookieOptions>`; на запрос — - `Middleware/SessionMiddleware.cs` — singleton; опции через `IOptionsMonitor<CookieOptions>`; на запрос —
scope через `RequestServices``AuthService.ResolveSessionAsync`; при пользователе: `SetCurrentUser` + scope через `RequestServices``AuthService.ResolveSessionAsync`; при пользователе: `SetCurrentUser` +
`_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")))`; `finally → Reset()`. Pass-through без 401. `_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")))`; `finally → Reset()`. Pass-through без 401.
- `Endpoints/AuthEndpoints.cs``MapAuthEndpoints(this IEndpointRouteBuilder)`, группа `/api/auth` - `Endpoints/AuthEndpoints.cs``MapAuthEndpoints(this IEndpointRouteBuilder)`, группа `/api/auth`
с тегом OpenAPI `auth`; логика ответов и сообщений по прототипу; выставление куки (httpOnly, SameSite=Lax, с тегом OpenAPI `auth`; логика ответов и сообщений по прототипу; выставление куки (httpOnly, SameSite=Lax,
Path=/, MaxAge=`TimeSpan.FromDays(options.Days)`, Secure из конфига); удаление куки на logout. Path=/, MaxAge=`TimeSpan.FromDays(options.Days)`, Secure из конфига); удаление куки на logout.
- `Endpoints/LoginRequest.cs`, `Endpoints/ChangePasswordRequest.cs` — record-тела (входящий JSON camelCase, - `Endpoints/LoginRequest.cs`, `Endpoints/ChangePasswordRequest.cs` — record-тела (входящий JSON camelCase,
System.Text.Json case-insensitive по умолчанию). System.Text.Json case-insensitive по умолчанию).
- `Hosting/StartupSeed.cs` — минимальный seed (Ruling 8): `EnsureSeedAsync(sp, ct)`. Через scope: пуст ли - `Hosting/StartupSeed.cs` — минимальный seed (Ruling 8): `EnsureSeedAsync(sp, ct)`. Через scope: пуст ли
`ITenantRepository.ListAsync` → создать тенанта `Default`/`active`; пользователь `admin` из env `ITenantRepository.ListAsync` → создать тенанта `Default`/`active`; пользователь `admin` из env
`DEAL_BOOTSTRAP_LOGIN/PASSWORD` (default admin/admin), хэш `IPasswordHasher`; идемпотентно (если `DEAL_BOOTSTRAP_LOGIN/PASSWORD` (default admin/admin), хэш `IPasswordHasher`; идемпотентно (если
пользователь есть — no-op). Провижинер не используется. пользователь есть — no-op). Провижинер не используется.
- `Hosting/PendingTenantProvisioner.cs` — см. Отклонение 1. - `Hosting/PendingTenantProvisioner.cs` — см. Отклонение 1.
### Изменены ### Изменены
- `src/core/Deal.Api/Program.cs``AddTenantsModule()` + `AddDealPersistence()`, временная регистрация - `src/core/Deal.Api/Program.cs``AddTenantsModule()` + `AddDealPersistence()`, временная регистрация
`ITenantProvisioner` (Отклонение 1), `Configure<CookieOptions>(GetSection("Cookies"))`, `ITenantProvisioner` (Отклонение 1), `Configure<CookieOptions>(GetSection("Cookies"))`,
`ConfigureHttpJsonOptions` (UnsafeRelaxedJsonEscaping — Отклонение 3), dev-CORS `ConfigureHttpJsonOptions` (UnsafeRelaxedJsonEscaping — Отклонение 3), dev-CORS
(`SetIsOriginAllowed(_ => true).AllowCredentials().AllowAnyHeader().AllowAnyMethod()` + комментарий про (`SetIsOriginAllowed(_ => true).AllowCredentials().AllowAnyHeader().AllowAnyMethod()` + комментарий про
Cloudflare/прод), `UseCors``UseMiddleware<SessionMiddleware>``MapAuthEndpoints()`, Cloudflare/прод), `UseCors``UseMiddleware<SessionMiddleware>``MapAuthEndpoints()`,
`await StartupSeed.EnsureSeedAsync(...)` перед `Run()`. `public partial class Program` сохранён. `await StartupSeed.EnsureSeedAsync(...)` перед `Run()`. `public partial class Program` сохранён.
- `src/core/Deal.Api/appsettings.json` + `appsettings.Development.json` — секция `Cookies` - `src/core/Deal.Api/appsettings.json` + `appsettings.Development.json` — секция `Cookies`
(Name/Days/Secure); ConnectionStrings в dev-файле не тронуты (Отклонение 2). (Name/Days/Secure); ConnectionStrings в dev-файле не тронуты (Отклонение 2).
- `src/core/Deal.Api/Deal.Api.csproj` — явный ProjectReference на `Deal.Modules.Tenants` (композиционный - `src/core/Deal.Api/Deal.Api.csproj` — явный ProjectReference на `Deal.Modules.Tenants` (композиционный
корень регистрирует модуль напрямую; до этого модуль был доступен только транзитивно через Infrastructure). корень регистрирует модуль напрямую; до этого модуль был доступен только транзитивно через Infrastructure).
- `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs` — в интерфейс добавлены `SetTenant(TenantId)` и - `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs` — в интерфейс добавлены `SetTenant(TenantId)` и
`Reset()` (SetTenant был только на классе-реализации; middleware ходит через интерфейс). `Reset()` (SetTenant был только на классе-реализации; middleware ходит через интерфейс).
- `src/core/Deal.Infrastructure/Data/TenantContext.cs` — реализация `Reset()` (обнуляет AsyncLocal). - `src/core/Deal.Infrastructure/Data/TenantContext.cs` — реализация `Reset()` (обнуляет AsyncLocal).
### Создан — `src/core/Deal.Infrastructure/` ### Создан — `src/core/Deal.Infrastructure/`
- `ServiceCollectionExtensions.cs``AddDealPersistence(this IServiceCollection)`: scoped - `ServiceCollectionExtensions.cs``AddDealPersistence(this IServiceCollection)`: scoped
`IAuthStore → AuthStore`, `ITenantRepository → TenantRepository` (порт&адаптер; XML-doc про времена жизни). `IAuthStore → AuthStore`, `ITenantRepository → TenantRepository` (порт&адаптер; XML-doc про времена жизни).
## Команды и вывод ## Команды и вывод
### `dotnet build Deal.sln --nologo` ### `dotnet build Deal.sln --nologo`
``` ```
Сборка успешно выполнено через 2,1 с # 0 warnings / 0 errors (TreatWarningsAsErrors) Сборка успешно выполнено через 2,1 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
``` ```
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo` ### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
``` ```
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,8 с Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,8 с
``` ```
### curl-приёмка (`sh .superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`, :5080) ### curl-приёмка (`sh .superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`, :5080)
Скрипт: `ASPNETCORE_ENVIRONMENT=Development` + apphost `Deal.Api.exe --urls http://localhost:5080`, Скрипт: `ASPNETCORE_ENVIRONMENT=Development` + apphost `Deal.Api.exe --urls http://localhost:5080`,
jar-файлы `/tmp/task4-jar-{old,new}.txt`, kill по завершении (trap EXIT). Выводы по шагам (1-й прогон): jar-файлы `/tmp/task4-jar-{old,new}.txt`, kill по завершении (trap EXIT). Выводы по шагам (1-й прогон):
``` ```
1. health → {"ok":true,"service":"deal"} 1. health → {"ok":true,"service":"deal"}
2. login admin/admin (jar-old) 2. login admin/admin (jar-old)
Set-Cookie: deal_session=ndz84oBA55R8uM2bEcGI0orKB4fJ8jezKVcDcrGHf6M; max-age=2592000; path=/; samesite=lax; httponly Set-Cookie: deal_session=ndz84oBA55R8uM2bEcGI0orKB4fJ8jezKVcDcrGHf6M; max-age=2592000; path=/; samesite=lax; httponly
→ 200 {"ok":true,"login":"admin"} → 200 {"ok":true,"login":"admin"}
3. me (jar-old) → 200 {"login":"admin","ok":true} 3. me (jar-old) → 200 {"login":"admin","ok":true}
4. login admin/wrong → 401 {"detail":"Неверный логин или пароль"} 4. login admin/wrong → 401 {"detail":"Неверный логин или пароль"}
5. change-password admin→admin2 (jar-old, новая кука в jar-new) 5. change-password admin→admin2 (jar-old, новая кука в jar-new)
Set-Cookie: deal_session=jPJbNRfO_jh3WaOevTEhYSRlKV8gDD6llW7sIJxLzCk; ... httponly Set-Cookie: deal_session=jPJbNRfO_jh3WaOevTEhYSRlKV8gDD6llW7sIJxLzCk; ... httponly
→ 200 {"ok":true} → 200 {"ok":true}
6. logout (jar-old) → 200 {"ok":true} 6. logout (jar-old) → 200 {"ok":true}
7. me (jar-old) → 401 {"detail":"Требуется авторизация"} 7. me (jar-old) → 401 {"detail":"Требуется авторизация"}
8. me (jar-new) → 200 {"login":"admin","ok":true} 8. me (jar-new) → 200 {"login":"admin","ok":true}
9. login admin/admin → 401 {"detail":"Неверный логин или пароль"} 9. login admin/admin → 401 {"detail":"Неверный логин или пароль"}
10. login admin/admin2 (jar-old) → 200 {"ok":true,"login":"admin"} 10. login admin/admin2 (jar-old) → 200 {"ok":true,"login":"admin"}
11. change-password admin2→admin (jar-new) → 200 {"ok":true} + свежая кука 11. change-password admin2→admin (jar-new) → 200 {"ok":true} + свежая кука
12. me (jar-new) → 200 {"login":"admin","ok":true} 12. me (jar-new) → 200 {"login":"admin","ok":true}
``` ```
Кука в Set-Cookie: `httpOnly`, `samesite=lax`, `max-age=2592000` (30 дней), `path=/`. Сервер останавливается Кука в Set-Cookie: `httpOnly`, `samesite=lax`, `max-age=2592000` (30 дней), `path=/`. Сервер останавливается
скриптом (проверено: после прогона порт :5080 не отвечает). Второй прогон подряд — успешен (идемпотентный seed). скриптом (проверено: после прогона порт :5080 не отвечает). Второй прогон подряд — успешен (идемпотентный seed).
### psql (после приёмки) ### psql (после приёмки)
``` ```
tenants: b15066ee-126a-4a3b-9c80-1b36a526d33d | Default | active tenants: b15066ee-126a-4a3b-9c80-1b36a526d33d | Default | active
users: admin | active | $argon2id$v=... users: admin | active | $argon2id$v=...
sessions: 1 (последняя свежая сессия после финального change-password) sessions: 1 (последняя свежая сессия после финального change-password)
``` ```
## Отклонения и решения ## Отклонения и решения
1. **DI: scoped `TenantService` не собирается до Task 5** — on-build валидация DI в Development падает: 1. **DI: scoped `TenantService` не собирается до Task 5** — on-build валидация DI в Development падает:
`Unable to resolve service for type 'ITenantProvisioner' while attempting to activate 'TenantService'` `Unable to resolve service for type 'ITenantProvisioner' while attempting to activate 'TenantService'`
(реализация провижинера — файл Task 5). Решение: временная заглушка `Deal.Api/Hosting/PendingTenantProvisioner.cs` (реализация провижинера — файл Task 5). Решение: временная заглушка `Deal.Api/Hosting/PendingTenantProvisioner.cs`
(бросает `InvalidOperationException` при вызове; в Task 4 провижининг не вызывается), регистрация в (бросает `InvalidOperationException` при вызове; в Task 4 провижининг не вызывается), регистрация в
`Program.cs` с комментарием «удалить в Task 5». `Program.cs` с комментарием «удалить в Task 5».
2. **`Cookies` добавлена и в базовый `appsettings.json`** (задача упоминала только Development-файл): иначе при 2. **`Cookies` добавлена и в базовый `appsettings.json`** (задача упоминала только Development-файл): иначе при
`ASPNETCORE_ENVIRONMENT != Development` `Days` остался бы 0 (cookie session-only). В Dev-файле секция `ASPNETCORE_ENVIRONMENT != Development` `Days` остался бы 0 (cookie session-only). В Dev-файле секция
продублирована по заданию; env-переменные `Cookies__*` перекрывают обе. продублирована по заданию; env-переменные `Cookies__*` перекрывают обе.
3. **JSON-энкодер `UnsafeRelaxedJsonEscaping`**: без него System.Text.Json экранирует кириллицу (`\uXXXX`), 3. **JSON-энкодер `UnsafeRelaxedJsonEscaping`**: без него System.Text.Json экранирует кириллицу (`\uXXXX`),
а прототип FastAPI отдаёт raw UTF-8 (проверено в curl: `{"detail":"Неверный логин или пароль"}`). а прототип FastAPI отдаёт raw UTF-8 (проверено в curl: `{"detail":"Неверный логин или пароль"}`).
Фронт парсит оба варианта; выбран байт-1:1 с прототипом. Фронт парсит оба варианта; выбран байт-1:1 с прототипом.
4. **Seed создаёт пользователя через `DealDbContext` напрямую**, а не через `IAuthStore`: в порте нет операции 4. **Seed создаёт пользователя через `DealDbContext` напрямую**, а не через `IAuthStore`: в порте нет операции
создания пользователя (он только читает/обновляет). Расширять интерфейс модуля ради временного seed не стали — создания пользователя (он только читает/обновляет). Расширять интерфейс модуля ради временного seed не стали —
зафиксировано в XML-doc `StartupSeed`. Тенант создаётся через `ITenantRepository`, существование пользователя зафиксировано в XML-doc `StartupSeed`. Тенант создаётся через `ITenantRepository`, существование пользователя
проверяется через `IAuthStore.FindUserByLoginAsync`. проверяется через `IAuthStore.FindUserByLoginAsync`.
5. **Алиасы `using CookieOptions = Deal.Api.Configuration.CookieOptions`** в 3 файлах: имя совпадает с 5. **Алиасы `using CookieOptions = Deal.Api.Configuration.CookieOptions`** в 3 файлах: имя совпадает с
`Microsoft.AspNetCore.Http.CookieOptions` (неявный using Web SDK) → CS0104. В `AuthEndpoints` второй алиас — `Microsoft.AspNetCore.Http.CookieOptions` (неявный using Web SDK) → CS0104. В `AuthEndpoints` второй алиас —
`AspNetCoreCookieOptions` для типа из `Microsoft.AspNetCore.Http`. `AspNetCoreCookieOptions` для типа из `Microsoft.AspNetCore.Http`.
6. Login в ответе нормализуется в нижний регистр (поведение AuthService из Task 3, осознанно строже прототипа, 6. Login в ответе нормализуется в нижний регистр (поведение AuthService из Task 3, осознанно строже прототипа,
который возвращает `body.login.strip()` как есть). На приёмку не влияет (admin → admin). который возвращает `body.login.strip()` как есть). На приёмку не влияет (admin → admin).
## Проверки (Acceptance) ## Проверки (Acceptance)
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅ 1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅ 2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
3. curl-цепочка (п.7) проходит; кука httpOnly в заголовке Set-Cookie. ✅ 3. curl-цепочка (п.7) проходит; кука httpOnly в заголовке Set-Cookie. ✅
4. Стиль: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без магических строк/чисел 4. Стиль: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без магических строк/чисел
(константы сообщений, имён env, префиксов). `.editorconfig`/`Directory.Build.props` и другие модули не тронуты. ✅ (константы сообщений, имён env, префиксов). `.editorconfig`/`Directory.Build.props` и другие модули не тронуты. ✅
Скрипт приёмки оставлен: `.superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`. Скрипт приёмки оставлен: `.superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`.
@@ -1,95 +1,95 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 5 функциональная приёмка: bootstrap + провижининг схемы при старте (см. план Task 5, п.6). # Task 5 функциональная приёмка: bootstrap + провижининг схемы при старте (см. план Task 5, п.6).
# Сценарий: чистый старт (seed тенанта+admin, схема tenant_000..001 с settings и историей, # Сценарий: чистый старт (seed тенанта+admin, схема tenant_000..001 с settings и историей,
# login admin/admin) → повторный старт (идемпотентность, без дублей и ошибок). Проект НЕ git. # login admin/admin) → повторный старт (идемпотентность, без дублей и ошибок). Проект НЕ git.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_EXE="C:/telbase/src/core/Deal.Api/bin/Debug/net10.0/Deal.Api.exe" API_EXE="C:/telbase/src/core/Deal.Api/bin/Debug/net10.0/Deal.Api.exe"
PSQL="docker exec deal-postgres psql -U deal -d deal" PSQL="docker exec deal-postgres psql -U deal -d deal"
LOG_RUN1="/tmp/task5-api-run1.log" LOG_RUN1="/tmp/task5-api-run1.log"
LOG_RUN2="/tmp/task5-api-run2.log" LOG_RUN2="/tmp/task5-api-run2.log"
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов. # Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
taskkill //F //IM Deal.Api.exe 2>/dev/null || true taskkill //F //IM Deal.Api.exe 2>/dev/null || true
rm -f "$LOG_RUN1" "$LOG_RUN2" rm -f "$LOG_RUN1" "$LOG_RUN2"
echo "============================================================" echo "============================================================"
echo "== RUN 1: чистый старт (база пуста) ==" echo "== RUN 1: чистый старт (база пуста) =="
echo "============================================================" echo "============================================================"
cd "C:/telbase/src/core/Deal.Api" || exit 1 cd "C:/telbase/src/core/Deal.Api" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN1" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN1" 2>&1 &
PID1=$! PID1=$!
echo "-- ожидание старта (15 c) --" echo "-- ожидание старта (15 c) --"
sleep 15 sleep 15
echo echo
echo "== 1a. tenants (ожидаем 1 строку с фикс. id) ==" echo "== 1a. tenants (ожидаем 1 строку с фикс. id) =="
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;' $PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
echo echo
echo "== 1b. users (ожидаем admin) ==" echo "== 1b. users (ожидаем admin) =="
$PSQL -c 'SELECT "Login" FROM public.users;' $PSQL -c 'SELECT "Login" FROM public.users;'
echo echo
echo "== 1c. схемы (ожидаем tenant_000...001) ==" echo "== 1c. схемы (ожидаем tenant_000...001) =="
$PSQL -c '\dn' $PSQL -c '\dn'
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
echo echo
echo "== 1d. таблицы в схеме тенанта (ожидаем settings + __TenantMigrationsHistory) ==" echo "== 1d. таблицы в схеме тенанта (ожидаем settings + __TenantMigrationsHistory) =="
$PSQL -c "\\dt $SCHEMA.*" $PSQL -c "\\dt $SCHEMA.*"
echo echo
echo "== 1d2. история tenant-миграций (ожидаем InitialTenant) ==" echo "== 1d2. история tenant-миграций (ожидаем InitialTenant) =="
$PSQL -c "SELECT \"MigrationId\" FROM \"$SCHEMA\".\"__TenantMigrationsHistory\";" $PSQL -c "SELECT \"MigrationId\" FROM \"$SCHEMA\".\"__TenantMigrationsHistory\";"
echo echo
echo "== 1e. login admin/admin ==" echo "== 1e. login admin/admin =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
echo echo
echo "== хвост лога run1 (ошибки?) ==" echo "== хвост лога run1 (ошибки?) =="
tail -n 8 "$LOG_RUN1" tail -n 8 "$LOG_RUN1"
echo echo
echo "== остановка run1 (pid $PID1) ==" echo "== остановка run1 (pid $PID1) =="
kill "$PID1" 2>/dev/null kill "$PID1" 2>/dev/null
sleep 2 sleep 2
echo echo
echo "============================================================" echo "============================================================"
echo "== RUN 2: повторный старт (идемпотентность) ==" echo "== RUN 2: повторный старт (идемпотентность) =="
echo "============================================================" echo "============================================================"
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN2" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN2" 2>&1 &
PID2=$! PID2=$!
echo "-- ожидание старта (12 c) --" echo "-- ожидание старта (12 c) --"
sleep 12 sleep 12
echo echo
echo "== 2a. tenants (ожидаем по-прежнему 1 строку) ==" echo "== 2a. tenants (ожидаем по-прежнему 1 строку) =="
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;' $PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
echo echo
echo "== 2b. users (ожидаем по-прежнему admin, 1 строку) ==" echo "== 2b. users (ожидаем по-прежнему admin, 1 строку) =="
$PSQL -c 'SELECT "Login" FROM public.users;' $PSQL -c 'SELECT "Login" FROM public.users;'
echo echo
echo "== 2c. логин снова работает ==" echo "== 2c. логин снова работает =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
echo echo
echo "== хвост лога run2 (ошибок быть не должно) ==" echo "== хвост лога run2 (ошибок быть не должно) =="
tail -n 8 "$LOG_RUN2" tail -n 8 "$LOG_RUN2"
echo echo
echo "== остановка run2 (pid $PID2) ==" echo "== остановка run2 (pid $PID2) =="
kill "$PID2" 2>/dev/null kill "$PID2" 2>/dev/null
sleep 2 sleep 2
taskkill //F //IM Deal.Api.exe 2>/dev/null || true taskkill //F //IM Deal.Api.exe 2>/dev/null || true
echo "== done ==" echo "== done =="
@@ -1,175 +1,175 @@
# Task 5 — Провижининг схем тенантов и bootstrap при старте. Отчёт # Task 5 — Провижининг схем тенантов и bootstrap при старте. Отчёт
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md` Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`
(Task 5, Rulings 3 и 8). Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; функциональная приёмка (Task 5, Rulings 3 и 8). Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; функциональная приёмка
на :5080 — два старта: первый создаёт seed и схему, повторный идемпотентен (дублей и ошибок нет), login admin/admin работает. на :5080 — два старта: первый создаёт seed и схему, повторный идемпотентен (дублей и ошибок нет), login admin/admin работает.
## Итог ## Итог
Реализован механизм провижининга схем тенантов (Ruling 3) и bootstrap при старте (Ruling 8): Реализован механизм провижининга схем тенантов (Ruling 3) и bootstrap при старте (Ruling 8):
`TenantProvisioningService` создаёт схему `tenant_<id>` и применяет tenant-миграции с историей `TenantProvisioningService` создаёт схему `tenant_<id>` и применяет tenant-миграции с историей
`__TenantMigrationsHistory` в схеме тенанта; `TenantBootstrapService` (IHostedService) при старте создаёт `__TenantMigrationsHistory` в схеме тенанта; `TenantBootstrapService` (IHostedService) при старте создаёт
дефолтного тенанта с фиксированным id и пользователя admin из env, затем провижинит схемы всех тенантов реестра. дефолтного тенанта с фиксированным id и пользователя admin из env, затем провижинит схемы всех тенантов реестра.
Временные заглушки Task 4 (`StartupSeed`, `PendingTenantProvisioner`) удалены; seed-код больше не трогает Временные заглушки Task 4 (`StartupSeed`, `PendingTenantProvisioner`) удалены; seed-код больше не трогает
`DealDbContext` в Api. «30 дней» унифицировано: код-дефолт куки ссылается на `AuthService.SessionLifetimeDays`. `DealDbContext` в Api. «30 дней» унифицировано: код-дефолт куки ссылается на `AuthService.SessionLifetimeDays`.
## Файлы ## Файлы
### Создан — `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs` ### Создан — `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs`
Реализация `ITenantProvisioner` (порт модуля Tenants), зависимость — `ConnectionStringProvider`. Реализация `ITenantProvisioner` (порт модуля Tenants), зависимость — `ConnectionStringProvider`.
`ProvisionAsync(TenantId, ct)`: `ProvisionAsync(TenantId, ct)`:
1. Открывает `NpgsqlConnection` на базовой строке (`ForTenant(null)`) и выполняет 1. Открывает `NpgsqlConnection` на базовой строке (`ForTenant(null)`) и выполняет
`TenantSchemaMigrator.CreateSchemaSql(tenantId.SchemaName)``CREATE SCHEMA IF NOT EXISTS` `TenantSchemaMigrator.CreateSchemaSql(tenantId.SchemaName)``CREATE SCHEMA IF NOT EXISTS`
(DDL-идентификатор экранирует хелпер). Соединение закрывается через `await using`. (DDL-идентификатор экранирует хелпер). Соединение закрывается через `await using`.
2. Собирает опции `TenantDbContext` на строке `ForTenant(tenantId)` (уже с `Search Path=tenant_<id>`) с 2. Собирает опции `TenantDbContext` на строке `ForTenant(tenantId)` (уже с `Search Path=tenant_<id>`) с
`npgsql.MigrationsHistoryTable("__TenantMigrationsHistory", tenantId.SchemaName)` и вызывает `Database.MigrateAsync`. `npgsql.MigrationsHistoryTable("__TenantMigrationsHistory", tenantId.SchemaName)` и вызывает `Database.MigrateAsync`.
3. Идемпотентность + защита от гонки параллельных провижинингов одного тенанта: статический 3. Идемпотентность + защита от гонки параллельных провижинингов одного тенанта: статический
`ConcurrentDictionary<string, SemaphoreSlim>` по имени схемы (`GetOrAdd` + `WaitAsync` + try/finally `Release`). `ConcurrentDictionary<string, SemaphoreSlim>` по имени схемы (`GetOrAdd` + `WaitAsync` + try/finally `Release`).
Больше никакой синхронизации не добавлено. Больше никакой синхронизации не добавлено.
Комментарий: dev-роль `deal` имеет DDL-права — приемлемо для dev; в проде у прикладной роли DDL нет, Комментарий: dev-роль `deal` имеет DDL-права — приемлемо для dev; в проде у прикладной роли DDL нет,
миграции применяет служебная роль. миграции применяет служебная роль.
### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` ### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs`
`AddDealPersistence` дополнительно регистрирует `services.AddScoped<ITenantProvisioner, TenantProvisioningService>()`. `AddDealPersistence` дополнительно регистрирует `services.AddScoped<ITenantProvisioner, TenantProvisioningService>()`.
`ConnectionStringProvider` в Infrastructure не регистрируется: он singleton в `Program.cs` (проверено). `ConnectionStringProvider` в Infrastructure не регистрируется: он singleton в `Program.cs` (проверено).
### Изменён — `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs` ### Изменён — `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs`
Добавлен порт `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (XML-doc: создаёт пользователя, Добавлен порт `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (XML-doc: создаёт пользователя,
Id задаёт вызывающий) — seed через порт модуля, а не через DbContext в Api. Id задаёт вызывающий) — seed через порт модуля, а не через DbContext в Api.
### Изменён — `src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs` ### Изменён — `src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs`
Реализация `CreateUserAsync`: маппинг DTO → `UserEntity` (Id/Login/TenantId/Status/PasswordHash из DTO, Реализация `CreateUserAsync`: маппинг DTO → `UserEntity` (Id/Login/TenantId/Status/PasswordHash из DTO,
`CreatedAt = UtcNow`) + `SaveChangesAsync`. `CreatedAt = UtcNow`) + `SaveChangesAsync`.
### Изменён — `src/core/Deal.Modules.Tenants/Application/TenantService.cs` ### Изменён — `src/core/Deal.Modules.Tenants/Application/TenantService.cs`
Добавлена перегрузка `CreateTenantAsync(string name, Guid id, CancellationToken ct)` (для bootstrap с фиксированным Добавлена перегрузка `CreateTenantAsync(string name, Guid id, CancellationToken ct)` (для bootstrap с фиксированным
id, Ruling 8); прежняя `CreateTenantAsync(name, ct)` делегирует в неё с `Guid.NewGuid()`. Общий код id, Ruling 8); прежняя `CreateTenantAsync(name, ct)` делегирует в неё с `Guid.NewGuid()`. Общий код
сохранения (`TenantRepository.CreateAsync`) + провижининг (`ITenantProvisioner.ProvisionAsync`) вынесен в перегрузку с id. сохранения (`TenantRepository.CreateAsync`) + провижининг (`ITenantProvisioner.ProvisionAsync`) вынесен в перегрузку с id.
### Создан — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService) ### Создан — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService)
`StartAsync` (scope через `IServiceScopeFactory`): `StartAsync` (scope через `IServiceScopeFactory`):
1. `ITenantRepository.ListAsync` — если тенантов нет, создаёт дефолтного: фикс. id `00000000-0000-0000-0000-000000000001`, 1. `ITenantRepository.ListAsync` — если тенантов нет, создаёт дефолтного: фикс. id `00000000-0000-0000-0000-000000000001`,
имя `Default`, через `TenantService.CreateTenantAsync(name, id, ct)` (сам вызывает провижининг). имя `Default`, через `TenantService.CreateTenantAsync(name, id, ct)` (сам вызывает провижининг).
2. Если пользователя с логином (после нормализации lowercase/trim из env `DEAL_BOOTSTRAP_LOGIN`, дефолт `admin`) нет — 2. Если пользователя с логином (после нормализации lowercase/trim из env `DEAL_BOOTSTRAP_LOGIN`, дефолт `admin`) нет —
создаёт `StoredUserDto` (Id=`Guid.NewGuid()`, Login нормализованный, TenantId=фикс. id дефолтного, Status=`active`, создаёт `StoredUserDto` (Id=`Guid.NewGuid()`, Login нормализованный, TenantId=фикс. id дефолтного, Status=`active`,
PasswordHash=`IPasswordHasher.Hash(password)`) через `IAuthStore.CreateUserAsync`. Пароль из env PasswordHash=`IPasswordHasher.Hash(password)`) через `IAuthStore.CreateUserAsync`. Пароль из env
`DEAL_BOOTSTRAP_PASSWORD` (дефолт `admin`). `DEAL_BOOTSTRAP_PASSWORD` (дефолт `admin`).
3. Для ВСЕХ тенантов реестра (включая свежесозданного — повтор идемпотентен) — `ITenantProvisioner.ProvisionAsync`. 3. Для ВСЕХ тенантов реестра (включая свежесозданного — повтор идемпотентен) — `ITenantProvisioner.ProvisionAsync`.
`StopAsync` — пуст (ничего не держим). Использует только порты модулей + `IPasswordHasher`; `DealDbContext` в Api нет. `StopAsync` — пуст (ничего не держим). Использует только порты модулей + `IPasswordHasher`; `DealDbContext` в Api нет.
`IConfiguration` читается на уровне Api (env-креды). `IConfiguration` читается на уровне Api (env-креды).
### Изменён — `src/core/Deal.Api/Program.cs` ### Изменён — `src/core/Deal.Api/Program.cs`
Убрана временная регистрация `ITenantProvisioner → PendingTenantProvisioner` и вызов Убрана временная регистрация `ITenantProvisioner → PendingTenantProvisioner` и вызов
`StartupSeed.EnsureSeedAsync` перед `Run`; добавлен `builder.Services.AddHostedService<TenantBootstrapService>()`. `StartupSeed.EnsureSeedAsync` перед `Run`; добавлен `builder.Services.AddHostedService<TenantBootstrapService>()`.
### Удалены ### Удалены
- `src/core/Deal.Api/Hosting/StartupSeed.cs` — seed переехал в `TenantBootstrapService` (провижининг схем, порты). - `src/core/Deal.Api/Hosting/StartupSeed.cs` — seed переехал в `TenantBootstrapService` (провижининг схем, порты).
- `src/core/Deal.Api/Hosting/PendingTenantProvisioner.cs` — DI-заглушка Task 4 (была помечена «удалить в Task 5»). - `src/core/Deal.Api/Hosting/PendingTenantProvisioner.cs` — DI-заглушка Task 4 (была помечена «удалить в Task 5»).
### Изменён — `src/core/Deal.Modules.Tenants/Application/AuthService.cs` ### Изменён — `src/core/Deal.Modules.Tenants/Application/AuthService.cs`
`SessionLifetimeDays` стала `public const int SessionLifetimeDays = 30;` (XML-doc) — единый источник «30». `SessionLifetimeDays` стала `public const int SessionLifetimeDays = 30;` (XML-doc) — единый источник «30».
### Изменён — `src/core/Deal.Api/Configuration/CookieOptions.cs` ### Изменён — `src/core/Deal.Api/Configuration/CookieOptions.cs`
`public int Days { get; set; } = AuthService.SessionLifetimeDays;` — код-дефолт ссылается на константу модуля; `public int Days { get; set; } = AuthService.SessionLifetimeDays;` — код-дефолт ссылается на константу модуля;
XML-doc переписан (конфигурация `Cookies__Days` при необходимости перекрывает дефолт). XML-doc переписан (конфигурация `Cookies__Days` при необходимости перекрывает дефолт).
### Изменён — `src/core/Deal.Api/Endpoints/AuthEndpoints.cs` ### Изменён — `src/core/Deal.Api/Endpoints/AuthEndpoints.cs`
Комментарий в `SetSessionCookie` приведён к новой реальности (MaxAge = `Cookies:Days`, код-дефолт — константа модуля). Комментарий в `SetSessionCookie` приведён к новой реальности (MaxAge = `Cookies:Days`, код-дефолт — константа модуля).
### Изменены — `src/core/Deal.Api/appsettings.json`, `appsettings.Development.json` ### Изменены — `src/core/Deal.Api/appsettings.json`, `appsettings.Development.json`
Из секции `Cookies` убран `"Days": 30` — «30» больше не дублируется в конфиге; значение по умолчанию течёт из Из секции `Cookies` убран `"Days": 30` — «30» больше не дублируется в конфиге; значение по умолчанию течёт из
`AuthService.SessionLifetimeDays` (см. CookieOptions). `AuthService.SessionLifetimeDays` (см. CookieOptions).
### Изменён — `src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs` ### Изменён — `src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs`
Добавлена реализация `CreateUserAsync` (добавляет пользователя в in-memory список и пишет `create-user:{id}` в `Calls`) — Добавлена реализация `CreateUserAsync` (добавляет пользователя в in-memory список и пишет `create-user:{id}` в `Calls`) —
интерфейс `IAuthStore` расширен, тест-дублёр обязан его реализовать. интерфейс `IAuthStore` расширен, тест-дублёр обязан его реализовать.
### Создан — `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh` ### Создан — `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh`
Скрипт функциональной приёмки: два старта Deal.Api на :5080 с psql/curl-проверками и остановкой (см. ниже). Скрипт функциональной приёмки: два старта Deal.Api на :5080 с psql/curl-проверками и остановкой (см. ниже).
## Команды и вывод ## Команды и вывод
### `dotnet build Deal.sln --nologo` ### `dotnet build Deal.sln --nologo`
``` ```
Сборка успешно выполнено через 2,0 с # 0 warnings / 0 errors (TreatWarningsAsErrors) Сборка успешно выполнено через 2,0 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
``` ```
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo` ### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
``` ```
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
``` ```
### Функциональная приёмка (порт 5080) ### Функциональная приёмка (порт 5080)
База очищена заранее: `DELETE FROM public.users; DELETE FROM public.tenants;` (порядок важен: sessions База очищена заранее: `DELETE FROM public.users; DELETE FROM public.tenants;` (порядок важен: sessions
каскадно удаляются при удалении users; FK users→tenants — RESTRICT). В `public` до старта: 0 тенантов, 0 пользователей. каскадно удаляются при удалении users; FK users→tenants — RESTRICT). В `public` до старта: 0 тенантов, 0 пользователей.
**RUN 1 — чистый старт** (`Deal.Api.exe`, `ASPNETCORE_ENVIRONMENT=Development`, ожидание 15 c): **RUN 1 — чистый старт** (`Deal.Api.exe`, `ASPNETCORE_ENVIRONMENT=Development`, ожидание 15 c):
a. Тенанты — 1 строка с фиксированным id: a. Тенанты — 1 строка с фиксированным id:
``` ```
Id | Name | Status Id | Name | Status
--------------------------------------+---------+-------- --------------------------------------+---------+--------
00000000-0000-0000-0000-000000000001 | Default | active 00000000-0000-0000-0000-000000000001 | Default | active
``` ```
b. Пользователи: b. Пользователи:
``` ```
Login Login
------- -------
admin admin
``` ```
c. Схемы (`\dn`): `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`). c. Схемы (`\dn`): `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
d. Таблицы схемы тенанта (`\dt tenant_00000000000000000000000000000001.*`): d. Таблицы схемы тенанта (`\dt tenant_00000000000000000000000000000001.*`):
`__TenantMigrationsHistory` и `settings`. История миграций содержит одну строку: `__TenantMigrationsHistory` и `settings`. История миграций содержит одну строку:
``` ```
20260905193010_InitialTenant 20260905193010_InitialTenant
``` ```
e. Логин: e. Логин:
``` ```
{"ok":true,"login":"admin"} {"ok":true,"login":"admin"}
HTTP 200 HTTP 200
``` ```
Ошибок/предупреждений в логе run1 нет (grep `error|exception|fail|warn|crit` — пусто). Bootstrap-последовательность в логе: Ошибок/предупреждений в логе run1 нет (grep `error|exception|fail|warn|crit` — пусто). Bootstrap-последовательность в логе:
`SELECT tenants (пусто)``INSERT tenants``SELECT users``INSERT users``SELECT tenants` → старт слушателя. `SELECT tenants (пусто)``INSERT tenants``SELECT users``INSERT users``SELECT tenants` → старт слушателя.
**RUN 2 — повторный старт (идемпотентность):** **RUN 2 — повторный старт (идемпотентность):**
- Тенанты: по-прежнему 1 строка (`00000000-...-0001`, Default) — дублей нет. - Тенанты: по-прежнему 1 строка (`00000000-...-0001`, Default) — дублей нет.
- Пользователи: по-прежнему 1 (`admin`) — дублей нет. - Пользователи: по-прежнему 1 (`admin`) — дублей нет.
- Логин снова: `{"ok":true,"login":"admin"}` HTTP 200. - Логин снова: `{"ok":true,"login":"admin"}` HTTP 200.
- Лог run2: только `SELECT`-проверки (tenants → users → tenants для провижининга) — INSERT-ов seed нет, - Лог run2: только `SELECT`-проверки (tenants → users → tenants для провижининга) — INSERT-ов seed нет,
ошибок/предупреждений нет (идемпотентность подтверждена). ошибок/предупреждений нет (идемпотентность подтверждена).
- Оба процесса остановлены (kill + taskkill), порт :5080 свободен, процессов `Deal.Api` не осталось. - Оба процесса остановлены (kill + taskkill), порт :5080 свободен, процессов `Deal.Api` не осталось.
## Отклонения и решения ## Отклонения и решения
1. **Запуск не через `dotnet run`, а напрямую apphost `Deal.Api.exe`** (эквивалент `dotnet run --no-build`): 1. **Запуск не через `dotnet run`, а напрямую apphost `Deal.Api.exe`** (эквивалент `dotnet run --no-build`):
`dotnet run` порождает дочерний процесс приложения, который переживает kill родителя и «залипает» на порту; `dotnet run` порождает дочерний процесс приложения, который переживает kill родителя и «залипает» на порту;
прямой запуск собранного exe из Task 4 делал остановку детерминированной. Код тот же (сборка свежая, `--no-build` не требуется). прямой запуск собранного exe из Task 4 делал остановку детерминированной. Код тот же (сборка свежая, `--no-build` не требуется).
2. **psql-запросы используют кавычки для PascalCase-колонок** (`"Id"`, `"Name"`, `"Status"`, `"Login"`): Task 2 2. **psql-запросы используют кавычки для PascalCase-колонок** (`"Id"`, `"Name"`, `"Status"`, `"Login"`): Task 2
оставил колонки в именах C#-свойств (EF default, без snake_case). Буквальные команды из задания (`SELECT id, ...`) оставил колонки в именах C#-свойств (EF default, без snake_case). Буквальные команды из задания (`SELECT id, ...`)
падают с `column "id" does not exist`. Функциональный смысл проверок a–e выполнен; переименование колонок — падают с `column "id" does not exist`. Функциональный смысл проверок a–e выполнен; переименование колонок —
вне scope Task 5. вне scope Task 5.
3. **Из appsettings убран `Cookies:Days`** (см. задание «уберите дублирование „30“»): конфиг-секция больше не 3. **Из appsettings убран `Cookies:Days`** (см. задание «уберите дублирование „30“»): конфиг-секция больше не
дублирует число «30»; дефолт теперь единственный — `AuthService.SessionLifetimeDays`. Поведение не изменилось дублирует число «30»; дефолт теперь единственный — `AuthService.SessionLifetimeDays`. Поведение не изменилось
(кука остаётся `max-age=2592000`, проверено приёмкой Task 4 ранее; здесь логин/Set-Cookie отработали на новом дефолте). (кука остаётся `max-age=2592000`, проверено приёмкой Task 4 ранее; здесь логин/Set-Cookie отработали на новом дефолте).
Значение можно перекрыть env `Cookies__Days`. Значение можно перекрыть env `Cookies__Days`.
4. **В лог EF не пишутся DDL-команды провижининга** (CREATE SCHEMA выполняется сырым ADO-командами, DDL миграций — 4. **В лог EF не пишутся DDL-команды провижининга** (CREATE SCHEMA выполняется сырым ADO-командами, DDL миграций —
внутренним исполнителем EF без Command-событий 20101): факт применения подтверждён psql (таблицы и строка внутренним исполнителем EF без Command-событий 20101): факт применения подтверждён psql (таблицы и строка
`InitialTenant` в истории схемы), ошибок в логах нет. `InitialTenant` в истории схемы), ошибок в логах нет.
5. **Двойной провижининг свежесозданного тенанта в первом старте**: `CreateTenantAsync` уже провижинит схему, 5. **Двойной провижининг свежесозданного тенанта в первом старте**: `CreateTenantAsync` уже провижинит схему,
затем цикл по всем тенантам вызывает `ProvisionAsync` повторно — по заданию допустимо («можно пропустить затем цикл по всем тенантам вызывает `ProvisionAsync` повторно — по заданию допустимо («можно пропустить
свежесозданного»), выбрана более простая ветка «провижинить всех»: повтор идемпотентен. свежесозданного»), выбрана более простая ветка «провижинить всех»: повтор идемпотентен.
## Проверки (Acceptance Task 5) ## Проверки (Acceptance Task 5)
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅ 1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅ 2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
3. Старт с чистой БД: tenants — строка с фикс. id `00000000-0000-0000-0000-000000000001`, users — `admin`. ✅ 3. Старт с чистой БД: tenants — строка с фикс. id `00000000-0000-0000-0000-000000000001`, users — `admin`. ✅
4. Схема `tenant_00000000000000000000000000000001` создана; содержит `settings` и `__TenantMigrationsHistory` 4. Схема `tenant_00000000000000000000000000000001` создана; содержит `settings` и `__TenantMigrationsHistory`
с `InitialTenant`. ✅ с `InitialTenant`. ✅
5. Повторный старт идемпотентен: дублей нет (tenants/users по 1), ошибок в логах нет. ✅ 5. Повторный старт идемпотентен: дублей нет (tenants/users по 1), ошибок в логах нет. ✅
6. Login admin/admin работает после старта: `{"ok":true,"login":"admin"}`. ✅ 6. Login admin/admin работает после старта: `{"ok":true,"login":"admin"}`. ✅
7. Стиль: 1 тип = 1 файл; XML-doc на public-контрактах; комментарии на русском; «30» — единый источник 7. Стиль: 1 тип = 1 файл; XML-doc на public-контрактах; комментарии на русском; «30» — единый источник
(public const модуля); заглушки Task 4 удалены. Сторонние модули/`backend/` не тронуты. ✅ (public const модуля); заглушки Task 4 удалены. Сторонние модули/`backend/` не тронуты. ✅
Скрипт приёмки: `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh` (оставлен, как task-4-скрипт). Скрипт приёмки: `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh` (оставлен, как task-4-скрипт).
@@ -1,74 +1,74 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 6 финальная приёмка этапа 1 (см. план Task 6, п.2): полная curl-приёмка # Task 6 финальная приёмка этапа 1 (см. план Task 6, п.2): полная curl-приёмка
# (health, login, me, logout) + psql-проверка схем. Проект НЕ git. # (health, login, me, logout) + psql-проверка схем. Проект НЕ git.
# Предполагается: postgres из deploy/compose.dev.yml поднят, БД deal с системными миграциями. # Предполагается: postgres из deploy/compose.dev.yml поднят, БД deal с системными миграциями.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task6-jar.txt" JAR="/tmp/task6-jar.txt"
LOG="/tmp/task6-api.log" LOG="/tmp/task6-api.log"
PSQL="docker exec deal-postgres psql -U deal -d deal" PSQL="docker exec deal-postgres psql -U deal -d deal"
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов. # Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
taskkill //F //IM Deal.Api.exe 2>/dev/null || true taskkill //F //IM Deal.Api.exe 2>/dev/null || true
rm -f "$JAR" "$LOG" rm -f "$JAR" "$LOG"
echo "== запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
PID=$! PID=$!
cleanup() { cleanup() {
echo echo
echo "== остановка сервера (pid $PID) ==" echo "== остановка сервера (pid $PID) =="
kill "$PID" 2>/dev/null kill "$PID" 2>/dev/null
sleep 2 sleep 2
taskkill //F //IM Deal.Api.exe 2>/dev/null || true taskkill //F //IM Deal.Api.exe 2>/dev/null || true
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
sleep 12 sleep 12
echo echo
echo "== 1. GET /api/health — ожидаем 200 {ok:true,service:deal} ==" echo "== 1. GET /api/health — ожидаем 200 {ok:true,service:deal} =="
curl -s -w "\nHTTP %{http_code}\n" "$BASE_URL/api/health" curl -s -w "\nHTTP %{http_code}\n" "$BASE_URL/api/health"
echo echo
echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok:true,login:admin} + кука ==" echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok:true,login:admin} + кука =="
curl -s -c "$JAR" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \ curl -s -c "$JAR" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
echo echo
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} ==" echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR" "$BASE_URL/api/auth/me" curl -s -w "\nHTTP %{http_code}\n" -b "$JAR" "$BASE_URL/api/auth/me"
echo echo
echo "== 4. POST /api/auth/logout — ожидаем 200 {ok:true} ==" echo "== 4. POST /api/auth/logout — ожидаем 200 {ok:true} =="
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR" curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR"
echo echo
echo "== 5. psql: схемы (\dn) ==" echo "== 5. psql: схемы (\dn) =="
$PSQL -c '\dn' $PSQL -c '\dn'
echo echo
echo "== 6. psql: таблицы public (\dt public.*) ==" echo "== 6. psql: таблицы public (\dt public.*) =="
$PSQL -c '\dt public.*' $PSQL -c '\dt public.*'
echo echo
echo "== 7. psql: таблицы схемы тенанта ==" echo "== 7. psql: таблицы схемы тенанта =="
$PSQL -c '\dt tenant_00000000000000000000000000000001.*' $PSQL -c '\dt tenant_00000000000000000000000000000001.*'
echo echo
echo "== 8. psql: применённые миграции ==" echo "== 8. psql: применённые миграции =="
$PSQL -c 'SELECT "MigrationId" FROM public."__EFMigrationsHistory";' $PSQL -c 'SELECT "MigrationId" FROM public."__EFMigrationsHistory";'
$PSQL -c 'SELECT "MigrationId" FROM tenant_00000000000000000000000000000001."__TenantMigrationsHistory";' $PSQL -c 'SELECT "MigrationId" FROM tenant_00000000000000000000000000000001."__TenantMigrationsHistory";'
echo echo
echo "== хвост лога API (ошибок/предупреждений быть не должно) ==" echo "== хвост лога API (ошибок/предупреждений быть не должно) =="
tail -n 15 "$LOG" tail -n 15 "$LOG"
echo echo
echo "== done ==" echo "== done =="
@@ -1,97 +1,97 @@
# Task 6 — Финал этапа: техдок и полная проверка. Отчёт # Task 6 — Финал этапа: техдок и полная проверка. Отчёт
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md` Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`
(Task 6). Статус: **DONE (review pending)**. Сборка 0 warnings / 0 errors (`TreatWarningsAsErrors`), тесты 25 PASS, (Task 6). Статус: **DONE (review pending)**. Сборка 0 warnings / 0 errors (`TreatWarningsAsErrors`), тесты 25 PASS,
curl-приёмка health/login/me/logout на :5080 — все 200, psql-схемы и миграции на месте, `scripts/build.sh` curl-приёмка health/login/me/logout на :5080 — все 200, psql-схемы и миграции на месте, `scripts/build.sh`
и `scripts/test.sh` успешны. и `scripts/test.sh` успешны.
## 1. Изменения в техдоке `docs/technical/Техническая-документация-Дейл.md` ## 1. Изменения в техдоке `docs/technical/Техническая-документация-Дейл.md`
Код/конфиги продукта не менялись. Только три точечные правки техдока: Код/конфиги продукта не менялись. Только три точечные правки техдока:
1. **Новый раздел `## 13. Быстрый старт (dev, актуально для этапа 1)`** (в конец, после раздела 12) — 1. **Новый раздел `## 13. Быстрый старт (dev, актуально для этапа 1)`** (в конец, после раздела 12) —
фактический dev-путь этапа 1: фактический dev-путь этапа 1:
- Postgres: `docker compose -f deploy/compose.dev.yml up -d` (контейнер `deal-postgres`, порт 5433, БД `deal`); - Postgres: `docker compose -f deploy/compose.dev.yml up -d` (контейнер `deal-postgres`, порт 5433, БД `deal`);
- системные миграции из `src/core`: `dotnet ef database update --project Deal.Infrastructure - системные миграции из `src/core`: `dotnet ef database update --project Deal.Infrastructure
--startup-project Deal.Api --context DealDbContext` (public: tenants/users/sessions, `InitialSystem`); --startup-project Deal.Api --context DealDbContext` (public: tenants/users/sessions, `InitialSystem`);
- запуск: `dotnet run --project Deal.Api --urls http://localhost:5080` — при старте `TenantBootstrapService` - запуск: `dotnet run --project Deal.Api --urls http://localhost:5080` — при старте `TenantBootstrapService`
(идемпотентно) создаёт дефолтного тенанта `00000000-0000-0000-0000-000000000001` (схема (идемпотентно) создаёт дефолтного тенанта `00000000-0000-0000-0000-000000000001` (схема
`tenant_00000000000000000000000000000001` с `settings`, миграция `InitialTenant`) и пользователя `admin` `tenant_00000000000000000000000000000001` с `settings`, миграция `InitialTenant`) и пользователя `admin`
(env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`); (env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`);
- проверка auth: `POST /api/auth/login` → `{"ok":true,"login":"admin"}`, кука `deal_session` (30 дней, - проверка auth: `POST /api/auth/login` → `{"ok":true,"login":"admin"}`, кука `deal_session` (30 дней,
источник — константа `AuthService.SessionLifetimeDays`, перекрытие `Cookies__Days`); прочие эндпоинты источник — константа `AuthService.SessionLifetimeDays`, перекрытие `Cookies__Days`); прочие эндпоинты
и `/api/health`; и `/api/health`;
- psql-проверки схем: `\dn`, `\dt public.*`, `\dt tenant_*.*`; - psql-проверки схем: `\dn`, `\dt public.*`, `\dt tenant_*.*`;
- сборка/тесты: `sh scripts/build.sh`, `sh scripts/test.sh`. - сборка/тесты: `sh scripts/build.sh`, `sh scripts/test.sh`.
2. **Раздел 11 «Известные ограничения и TODO»** — добавлен блок «Выполнено на этапе 1 (2026-09-05)»: 2. **Раздел 11 «Известные ограничения и TODO»** — добавлен блок «Выполнено на этапе 1 (2026-09-05)»:
доступ/сессии (login/logout/me/change-password, кука 30 дней) и мультитенантность/миграции доступ/сессии (login/logout/me/change-password, кука 30 дней) и мультитенантность/миграции
(`InitialSystem`, схемы `tenant_<id>` с `InitialTenant`, провижининг + bootstrap). Остальные TODO (`InitialSystem`, схемы `tenant_<id>` с `InitialTenant`, провижининг + bootstrap). Остальные TODO
не переписаны, помечены заголовком «Остаётся TODO». не переписаны, помечены заголовком «Остаётся TODO».
3. **Раздел 4 «Мультитенантность и БД»** — добавлен подраздел «Фактическая схема на конец этапа 1» с 3. **Раздел 4 «Мультитенантность и БД»** — добавлен подраздел «Фактическая схема на конец этапа 1» с
фактическими таблицами: `public.tenants/users/sessions` (колонки по конвенции EF Core, PascalCase) и фактическими таблицами: `public.tenants/users/sessions` (колонки по конвенции EF Core, PascalCase) и
`settings` в схеме тенанта, история миграций, автоматический провижининг/bootstrap. Forward-looking `settings` в схеме тенанта, история миграций, автоматический провижининг/bootstrap. Forward-looking
списки («Ключевые таблицы public/схемы тенанта (пример)») сохранены без изменений, явно помечены как списки («Ключевые таблицы public/схемы тенанта (пример)») сохранены без изменений, явно помечены как
целевой вид будущих этапов. целевой вид будущих этапов.
## 2. Полная проверка этапа ## 2. Полная проверка этапа
Все команды выполнены фактически (не по памяти): Все команды выполнены фактически (не по памяти):
### `dotnet build Deal.sln --nologo` (из `src/core`) ### `dotnet build Deal.sln --nologo` (из `src/core`)
``` ```
Сборка успешно выполнено через 1,9 с Сборка успешно выполнено через 1,9 с
``` ```
0 warnings / 0 errors — гарантировано `TreatWarningsAsErrors=true` в `Directory.Build.props`. 0 warnings / 0 errors — гарантировано `TreatWarningsAsErrors=true` в `Directory.Build.props`.
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo` ### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
``` ```
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,9 с Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,9 с
``` ```
### `dotnet ef migrations list` (оба контекста, `--no-connect`) ### `dotnet ef migrations list` (оба контекста, `--no-connect`)
- `DealDbContext`: `20260905192825_InitialSystem`; `TenantDbContext`: `20260905193010_InitialTenant`. - `DealDbContext`: `20260905192825_InitialSystem`; `TenantDbContext`: `20260905193010_InitialTenant`.
Применение подтверждено psql (строки в `__EFMigrationsHistory` / `__TenantMigrationsHistory`). Применение подтверждено psql (строки в `__EFMigrationsHistory` / `__TenantMigrationsHistory`).
### psql (`docker exec deal-postgres psql -U deal -d deal`) ### psql (`docker exec deal-postgres psql -U deal -d deal`)
- `\dn`: схемы `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`). - `\dn`: схемы `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
- `\dt public.*`: `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (системная миграция применена). - `\dt public.*`: `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (системная миграция применена).
- `\dt tenant_00000000000000000000000000000001.*`: `settings`, `__TenantMigrationsHistory` - `\dt tenant_00000000000000000000000000000001.*`: `settings`, `__TenantMigrationsHistory`
(миграция `InitialTenant` применена на схему). (миграция `InitialTenant` применена на схему).
- Данные: 1 тенант (`00000000-0000-0000-0000-000000000001`, Default, active), 1 пользователь (`admin`, active). - Данные: 1 тенант (`00000000-0000-0000-0000-000000000001`, Default, active), 1 пользователь (`admin`, active).
### Живой прогон API на :5080 (скрипт `.superpowers/sdd/deal-stage1-tenancy/task-6-functional-check.sh`) ### Живой прогон API на :5080 (скрипт `.superpowers/sdd/deal-stage1-tenancy/task-6-functional-check.sh`)
Запуск `Deal.Api.exe` (apphost, Development) — bootstrap отработал идемпотентно (дублей нет, ошибок в логе нет): Запуск `Deal.Api.exe` (apphost, Development) — bootstrap отработал идемпотентно (дублей нет, ошибок в логе нет):
- `GET /api/health` → `{"ok":true,"service":"deal"}` HTTP 200; - `GET /api/health` → `{"ok":true,"service":"deal"}` HTTP 200;
- `POST /api/auth/login` {admin,admin} → `{"ok":true,"login":"admin"}` HTTP 200 + кука; - `POST /api/auth/login` {admin,admin} → `{"ok":true,"login":"admin"}` HTTP 200 + кука;
- `GET /api/auth/me` с кукой → `{"login":"admin","ok":true}` HTTP 200; - `GET /api/auth/me` с кукой → `{"login":"admin","ok":true}` HTTP 200;
- `POST /api/auth/logout` → `{"ok":true}` HTTP 200. - `POST /api/auth/logout` → `{"ok":true}` HTTP 200.
Процесс остановлен (kill + taskkill), проверено: процессов `Deal.Api` нет, порт освобождён. Процесс остановлен (kill + taskkill), проверено: процессов `Deal.Api` нет, порт освобождён.
### `sh scripts/build.sh && sh scripts/test.sh` (из корня репозитория) ### `sh scripts/build.sh && sh scripts/test.sh` (из корня репозитория)
- build.sh: `Сборка успешно выполнено` (0/0); - build.sh: `Сборка успешно выполнено` (0/0);
- test.sh: `Сводка теста: всего: 25; сбой: 0; успешно: 25`. - test.sh: `Сводка теста: всего: 25; сбой: 0; успешно: 25`.
## 3. Отклонения и решения ## 3. Отклонения и решения
1. **Запуск приёмки — через собранный apphost `Deal.Api.exe`, а не `dotnet run`** — как в Task 4/5: 1. **Запуск приёмки — через собранный apphost `Deal.Api.exe`, а не `dotnet run`** — как в Task 4/5:
`dotnet run` оставляет переживающий kill дочерний процесс; прямой запуск делает остановку `dotnet run` оставляет переживающий kill дочерний процесс; прямой запуск делает остановку
детерминированной. Код тот же (свежая сборка). детерминированной. Код тот же (свежая сборка).
2. **Создан артефакт-скрипт приёмки `task-6-functional-check.sh`** (по образцу task-4/task-5) — это не код 2. **Создан артефакт-скрипт приёмки `task-6-functional-check.sh`** (по образцу task-4/task-5) — это не код
и не конфиг продукта, а фиксация прогона в `.superpowers/sdd/`. и не конфиг продукта, а фиксация прогона в `.superpowers/sdd/`.
3. **Раздел 11 не содержал буквальных пунктов «про login»/«про tenant-схемы»** (там были только общие TODO) — 3. **Раздел 11 не содержал буквальных пунктов «про login»/«про tenant-схемы»** (там были только общие TODO) —
выполненные пункты добавлены отдельным блоком «Выполнено на этапе 1», остальной список не тронут. выполненные пункты добавлены отдельным блоком «Выполнено на этапе 1», остальной список не тронут.
4. **Раздел 4 уже упоминал users в forward-looking списке**, поэтому фактическая схема этапа 1 (включая 4. **Раздел 4 уже упоминал users в forward-looking списке**, поэтому фактическая схема этапа 1 (включая
`sessions` и tenant `settings`, которых в целевом списке нет) зафиксирована отдельным подразделом `sessions` и tenant `settings`, которых в целевом списке нет) зафиксирована отдельным подразделом
«Фактическая схема на конец этапа 1» — forward-looking текст не удалялся. «Фактическая схема на конец этапа 1» — forward-looking текст не удалялся.
5. **В техдоке указаны фактические (PascalCase) имена колонок** — по конвенции EF Core, как в БД 5. **В техдоке указаны фактические (PascalCase) имена колонок** — по конвенции EF Core, как в БД
(см. task-5-report, отклонение 2). (см. task-5-report, отклонение 2).
6. `dotnet ef migrations list` выполнялся с `--no-connect` (нет гарантии env для development-конфига); 6. `dotnet ef migrations list` выполнялся с `--no-connect` (нет гарантии env для development-конфига);
факт применения подтверждён psql-запросами к таблицам истории — расхождений нет. факт применения подтверждён psql-запросами к таблицам истории — расхождений нет.
## 4. Acceptance Task 6 ## 4. Acceptance Task 6
1. `scripts/build.sh` / `scripts/test.sh` успешны; `migrations list` — System: InitialSystem, Tenant: InitialTenant. ✅ 1. `scripts/build.sh` / `scripts/test.sh` успешны; `migrations list` — System: InitialSystem, Tenant: InitialTenant. ✅
2. Полная curl-приёмка: health, login, me, logout — все HTTP 200; psql-проверка схем на месте. ✅ 2. Полная curl-приёмка: health, login, me, logout — все HTTP 200; psql-проверка схем на месте. ✅
3. Техдок обновлён: раздел «Быстрый старт dev» (актуальные шаги), раздел 11 (выполнено на этапе 1), 3. Техдок обновлён: раздел «Быстрый старт dev» (актуальные шаги), раздел 11 (выполнено на этапе 1),
раздел 4 (фактическая схема). ✅ раздел 4 (фактическая схема). ✅
4. `task-6-report.md` написан; финальная строка в `progress.md` (`## Task status`). ✅ 4. `task-6-report.md` написан; финальная строка в `progress.md` (`## Task status`). ✅
@@ -1,57 +1,57 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md # SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Проект НЕ git: фиксация — отчёты задач и этот ledger.
## Todos ## Todos
- [x] T1: Аудит действий (входы/выходы/действия пользователей) - [x] T1: Аудит действий (входы/выходы/действия пользователей)
- [x] T2: История расхода токенов (token_usage_events) - [x] T2: История расхода токенов (token_usage_events)
- [x] T3: Аналитика (operator/analytics/*) + контракт - [x] T3: Аналитика (operator/analytics/*) + контракт
- [x] T4: Оператор-консоль (фронт) - [x] T4: Оператор-консоль (фронт)
- [x] T5: Страница активации инвайта (фронт) - [x] T5: Страница активации инвайта (фронт)
- [x] T6: Наблюдаемость ELK/Loki (Grafana-дашборды) - [x] T6: Наблюдаемость ELK/Loki (Grafana-дашборды)
- [x] T7: Приёмка/доки - [x] T7: Приёмка/доки
## Task status ## Task status
- **T1 (аудит)**: complete. `AuditEvents` + 16 констант; `Deal.Api/Http/AuditAppender.cs` — единая точка - **T1 (аудит)**: complete. `AuditEvents` + 16 констант; `Deal.Api/Http/AuditAppender.cs` — единая точка
записи (актор tenant/operator, IP, без секретов). Пишутся: выход тенанта и оператора, `invite_joined`, записи (актор tenant/operator, IP, без секретов). Пишутся: выход тенанта и оператора, `invite_joined`,
CRUD карточек/комментарии, CRUD контейнеров, сохранение настроек (только имена полей), включение канала, CRUD карточек/комментарии, CRUD контейнеров, сохранение настроек (только имена полей), включение канала,
привязка Telegram. Отчёт: task-b1-report.md. привязка Telegram. Отчёт: task-b1-report.md.
- **T2 (токены)**: complete. Таблица `public.token_usage_events` + миграция - **T2 (токены)**: complete. Таблица `public.token_usage_events` + миграция
`20260910152246_AddTokenUsageEvents`; порт `ITokenUsageEventStore` + сервис + EF-адаптер; запись в точке `20260910152246_AddTokenUsageEvents`; порт `ITokenUsageEventStore` + сервис + EF-адаптер; запись в точке
списания AI (`TokenUsageRecorder`) и ML (`GrpcMlClient`, оценка ≈chars/4). Применена к dev-Postgres. списания AI (`TokenUsageRecorder`) и ML (`GrpcMlClient`, оценка ≈chars/4). Применена к dev-Postgres.
- **T3 (аналитика)**: complete. `/api/operator/analytics/{overview,tokens,activity}` + расширенный - **T3 (аналитика)**: complete. `/api/operator/analytics/{overview,tokens,activity}` + расширенный
`/api/operator/audit` (actorId/offset/total). Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`. `/api/operator/audit` (actorId/offset/total). Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`.
Отчёт: task-b2-report.md. Отчёт: task-b2-report.md.
- **T4/T5 (фронт)**: complete. Hash-роутер без зависимостей (`#/`, `#/operator`, `#/join?code=…`); - **T4/T5 (фронт)**: complete. Hash-роутер без зависимостей (`#/`, `#/operator`, `#/join?code=…`);
разделы оператора (вход, тенанты+suspend/resume/impersonate, инвайты, лимиты, аудит, аналитика, health); разделы оператора (вход, тенанты+suspend/resume/impersonate, инвайты, лимиты, аудит, аналитика, health);
страница активации инвайта. Отчёты: task-f1-report.md, task-f2-report.md. страница активации инвайта. Отчёты: task-f1-report.md, task-f2-report.md.
Доправка координатора: impersonation теперь ставит httpOnly-куку `deal_session` на ответе Доправка координатора: impersonation теперь ставит httpOnly-куку `deal_session` на ответе
(`Deal.Api/Http/SessionCookieWriter.cs`, использован и в AuthEndpoints) — UI переходит в приложение, (`Deal.Api/Http/SessionCookieWriter.cs`, использован и в AuthEndpoints) — UI переходит в приложение,
JS-токен-обходной путь убран. JS-токен-обходной путь убран.
- **T6 (наблюдаемость)**: complete. Grafana provisioning: datasource Loki + дашборды - **T6 (наблюдаемость)**: complete. Grafana provisioning: datasource Loki + дашборды
`Deal-Auth/Errors/Rps/Logs`; promtail `pipeline_stages` (json → label level); раздел техдока. `Deal-Auth/Errors/Rps/Logs`; promtail `pipeline_stages` (json → label level); раздел техдока.
- **T7 (приёмка/доки)**: complete. Runtime-приёмка — см. раздел ниже. Доки: обновлены `docs/api/api-map.md` - **T7 (приёмка/доки)**: complete. Runtime-приёмка — см. раздел ниже. Доки: обновлены `docs/api/api-map.md`
(операторские ручки + аналитика, UI-пометка), `docs/technical/Техническая-документация-Дейл.md` (таблица (операторские ручки + аналитика, UI-пометка), `docs/technical/Техническая-документация-Дейл.md` (таблица
`token_usage_events`, оператор-консоль/аналитика/каталог аудита — §4 и §13.10), `docs/user-guide/ `token_usage_events`, оператор-консоль/аналитика/каталог аудита — §4 и §13.10), `docs/user-guide/
Инструкция-пользователя-Дейл.md` (активация инвайта + раздел оператора), `docs/superpowers/STATUS.md` Инструкция-пользователя-Дейл.md` (активация инвайта + раздел оператора), `docs/superpowers/STATUS.md`
(этап 10 в таблице, раздел «сделано», Manual-остаток). (этап 10 в таблице, раздел «сделано», Manual-остаток).
## Runtime-приёмка (Docker dev-стек) — ВЫПОЛНЕНА ## Runtime-приёмка (Docker dev-стек) — ВЫПОЛНЕНА
- Системная миграция `AddTokenUsageEvents` применена к dev-Postgres (:5433). - Системная миграция `AddTokenUsageEvents` применена к dev-Postgres (:5433).
- `deal-core` пересобран и перезапущен; healthy. - `deal-core` пересобран и перезапущен; healthy.
- Операторский вход `operator`/`operator` → 200 `{ok:true}`; `GET /api/operator/tenants` → 200. - Операторский вход `operator`/`operator` → 200 `{ok:true}`; `GET /api/operator/tenants` → 200.
- `GET /api/operator/analytics/overview` → `{tenantsTotal:2, tenantsActive:2, events:17, logins:11, - `GET /api/operator/analytics/overview` → `{tenantsTotal:2, tenantsActive:2, events:17, logins:11,
failedLogins:1}`. failedLogins:1}`.
- `GET /api/operator/analytics/tokens?groupBy=day|provider` → 200 (SQL-группировка работает). - `GET /api/operator/analytics/tokens?groupBy=day|provider` → 200 (SQL-группировка работает).
- `GET /api/operator/analytics/activity?limit=3` → реальная лента (eventType/actor/tenant/ip/at, total). - `GET /api/operator/analytics/activity?limit=3` → реальная лента (eventType/actor/tenant/ip/at, total).
- `GET /api/operator/audit` → 200. - `GET /api/operator/audit` → 200.
- Фронт: `npm run build` зелёный; dev-сервер отдаёт `#/operator` и `#/join`. Ядро/тесты: build 0/0, - Фронт: `npm run build` зелёный; dev-сервер отдаёт `#/operator` и `#/join`. Ядро/тесты: build 0/0,
core **1173/1173 PASS**. core **1173/1173 PASS**.
## Остатки/ограничения ## Остатки/ограничения
- `channel_created` зарезервирован каталога, но не эмитится (нет пользовательской ручки создания канала). - `channel_created` зарезервирован каталога, но не эмитится (нет пользовательской ручки создания канала).
- Идентичность в логах: access-лог пишет метод/путь/код (без login); полный аудит с актором — в `audit_log`. - Идентичность в логах: access-лог пишет метод/путь/код (без login); полный аудит с актором — в `audit_log`.
- Реальные Telegram/LLM-креды — вне рамок (по решению владельца). - Реальные Telegram/LLM-креды — вне рамок (по решению владельца).
@@ -1,73 +1,73 @@
# Task B1 report — аудит действий (T1) и история расхода токенов (T2) # Task B1 report — аудит действий (T1) и история расхода токенов (T2)
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`. Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`.
## T1. Аудит действий (входы/выходы/действия пользователей) ## T1. Аудит действий (входы/выходы/действия пользователей)
### Каталог событий ### Каталог событий
`Deal.Modules.Tenants/Application/AuditEvents.cs` — добавлены стабильные строковые константы: `Deal.Modules.Tenants/Application/AuditEvents.cs` — добавлены стабильные строковые константы:
`tenant_logout`, `operator_logout`, `invite_joined`, `card_created`, `card_moved`, `card_trashed`, `tenant_logout`, `operator_logout`, `invite_joined`, `card_created`, `card_moved`, `card_trashed`,
`card_restored`, `card_deleted`, `card_comment_added`, `container_created`, `container_updated`, `card_restored`, `card_deleted`, `card_comment_added`, `container_created`, `container_updated`,
`container_deleted`, `settings_updated`, `channel_enabled`, `channel_created`, `telegram_linked`. `container_deleted`, `settings_updated`, `channel_enabled`, `channel_created`, `telegram_linked`.
Значение `invite_activated` оставлено в каталоге как легаси этапа 7 (исторические данные); Значение `invite_activated` оставлено в каталоге как легаси этапа 7 (исторические данные);
`POST /api/join` теперь пишет `invite_joined`. `POST /api/join` теперь пишет `invite_joined`.
### Единая точка и актор ### Единая точка и актор
- Запись — только через существующий `AuditService` (append-only `public.audit_log`). - Запись — только через существующий `AuditService` (append-only `public.audit_log`).
- Новый хелпер `Deal.Api/Http/AuditAppender.cs`: `AppendTenantAsync` (актор `tenant` из разрешённой - Новый хелпер `Deal.Api/Http/AuditAppender.cs`: `AppendTenantAsync` (актор `tenant` из разрешённой
сессии: userId/tenantId + IP) и `AppendOperatorAsync` (актор `operator`). Детали — минимальные, без сессии: userId/tenantId + IP) и `AppendOperatorAsync` (актор `operator`). Детали — минимальные, без
секретов. Резолвит `AuditService` из `RequestServices` (scoped, `DealDbContext` системной схемы секретов. Резолвит `AuditService` из `RequestServices` (scoped, `DealDbContext` системной схемы
`public` доступен в tenant-запросе — архитектура не менялась). `public` доступен в tenant-запросе — архитектура не менялась).
### Точки записи ### Точки записи
| Событие | Место | | Событие | Место |
|---|---| |---|---|
| `tenant_logout` | `AuthEndpoints.LogoutAsync` (при живой сессии) | | `tenant_logout` | `AuthEndpoints.LogoutAsync` (при живой сессии) |
| `operator_logout` | `OperatorAuthEndpoints.LogoutAsync` | | `operator_logout` | `OperatorAuthEndpoints.LogoutAsync` |
| `invite_joined` | `JoinEndpoint` (актор — новый пользователь тенанта) | | `invite_joined` | `JoinEndpoint` (актор — новый пользователь тенанта) |
| `card_created` | `CardDetailsEndpoints.CreateCardAsync` | | `card_created` | `CardDetailsEndpoints.CreateCardAsync` |
| `card_moved` / `card_trashed` / `card_restored` / `card_deleted` / `card_comment_added` | `CardsEndpoints` | | `card_moved` / `card_trashed` / `card_restored` / `card_deleted` / `card_comment_added` | `CardsEndpoints` |
| `container_created` / `container_updated` / `container_deleted` | `ContainersEndpoints` (принятие ИИ-предложения — `container_updated`) | | `container_created` / `container_updated` / `container_deleted` | `ContainersEndpoints` (принятие ИИ-предложения — `container_updated`) |
| `settings_updated` | `SettingsEndpoints.PatchSettingsAsync` (в деталях только имена полей — без значений/секретов) | | `settings_updated` | `SettingsEndpoints.PatchSettingsAsync` (в деталях только имена полей — без значений/секретов) |
| `channel_enabled` | `TelegramEndpoints` (`SetMonitorAsync`, `MonitorAllAsync` при включении) | | `channel_enabled` | `TelegramEndpoints` (`SetMonitorAsync`, `MonitorAllAsync` при включении) |
| `telegram_linked` | `TelegramEndpoints` (фаза `ready` после start-qr/send-code/send-password) | | `telegram_linked` | `TelegramEndpoints` (фаза `ready` после start-qr/send-code/send-password) |
`channel_created` — константа каталога (резерв): пользовательской ручки создания канала пока нет, `channel_created` — константа каталога (резерв): пользовательской ручки создания канала пока нет,
каталог наполняется синхронизацией Telegram (системное действие, актор не `tenant`). каталог наполняется синхронизацией Telegram (системное действие, актор не `tenant`).
## T2. История расхода токенов (`public.token_usage_events`) ## T2. История расхода токенов (`public.token_usage_events`)
### Схема (системная, `--context DealDbContext`) ### Схема (системная, `--context DealDbContext`)
- `Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs` + `TokenUsageEventConfiguration.cs`. - `Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs` + `TokenUsageEventConfiguration.cs`.
- Таблица `public.token_usage_events`: `Id` (bigint identity), `TenantId` (uuid), `At` (timestamptz), - Таблица `public.token_usage_events`: `Id` (bigint identity), `TenantId` (uuid), `At` (timestamptz),
`Provider`/`Model`/`Kind` (text), `PromptTokens`/`CompletionTokens`/`TotalTokens` (bigint), `Provider`/`Model`/`Kind` (text), `PromptTokens`/`CompletionTokens`/`TotalTokens` (bigint),
`DetailJson` (text). Индексы `(TenantId, At)` и `(At)`; FK → `public.tenants` (Restrict). `DetailJson` (text). Индексы `(TenantId, At)` и `(At)`; FK → `public.tenants` (Restrict).
- Миграция: `Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs` - Миграция: `Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs`
(каталог вывода — как у существующих системных миграций `DealDbContext`), снапшот обновлён. (каталог вывода — как у существующих системных миграций `DealDbContext`), снапшот обновлён.
### Модуль/порт/адаптер ### Модуль/порт/адаптер
- Модели: `TokenUsageEventDto`, `TokenUsageEventQueryDto`, `TokenUsageAggregateDto`, - Модели: `TokenUsageEventDto`, `TokenUsageEventQueryDto`, `TokenUsageAggregateDto`,
`TokenUsageEventKinds` (`ai|ml`), `TokenUsageGroupBys` (`day|tenant|provider|model`), `TokenUsageSources`. `TokenUsageEventKinds` (`ai|ml`), `TokenUsageGroupBys` (`day|tenant|provider|model`), `TokenUsageSources`.
- Порт `ITokenUsageEventStore` (append-only + агрегаты), сервис `TokenUsageEventService` - Порт `ITokenUsageEventStore` (append-only + агрегаты), сервис `TokenUsageEventService`
(единая точка записи, `At=UTC-now`), EF-адаптер `TokenUsageEventStore`. (единая точка записи, `At=UTC-now`), EF-адаптер `TokenUsageEventStore`.
- Регистрация: `AddDealPersistence``ITokenUsageEventStore`; `AddTenantsModule``TokenUsageEventService`. - Регистрация: `AddDealPersistence``ITokenUsageEventStore`; `AddTenantsModule``TokenUsageEventService`.
- `TokenUsageRecorder` расширен: пишет событие истории для AI (`AddAsync(usage, provider, model, ct)`) - `TokenUsageRecorder` расширен: пишет событие истории для AI (`AddAsync(usage, provider, model, ct)`)
и для ML (`AddEstimatedAsync(text, provider, model, ct)` — оценка ≈chars/4, конвенция ai.proto; и для ML (`AddEstimatedAsync(text, provider, model, ct)` — оценка ≈chars/4, конвенция ai.proto;
бюджет/lifetime `aiTokenUsage` ML не затрагивает, т.к. вызов локальный). бюджет/lifetime `aiTokenUsage` ML не затрагивает, т.к. вызов локальный).
- AI-путь: `GrpcAiClassifier`/`GrpcAiTools` передают provider/model из `AiProviderConfigBuilder`. - AI-путь: `GrpcAiClassifier`/`GrpcAiTools` передают provider/model из `AiProviderConfigBuilder`.
- ML-путь: `GrpcMlClient.PredictAsync` пишет событие `kind=ml`, provider=`local`, model=`ml`. - ML-путь: `GrpcMlClient.PredictAsync` пишет событие `kind=ml`, provider=`local`, model=`ml`.
`LocalMlClient` (dev-заглушка без реального ML) событий не пишет — как и Local AI fallback. `LocalMlClient` (dev-заглушка без реального ML) событий не пишет — как и Local AI fallback.
- `TokenUsageRecorder` регистрируется в `AddDealIntegrations` независимо от AI-режима. - `TokenUsageRecorder` регистрируется в `AddDealIntegrations` независимо от AI-режима.
## Валидация ## Валидация
- `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors. - `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors.
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные. - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные.
- Новые тесты: `AuditEventsTests` (стабильность строк), `TokenUsageRecorderTests` (AI + ML события, - Новые тесты: `AuditEventsTests` (стабильность строк), `TokenUsageRecorderTests` (AI + ML события,
оценка токенов), `TokenUsageEventServiceTests`, `FakeTokenUsageEventStore`; обновлены оценка токенов), `TokenUsageEventServiceTests`, `FakeTokenUsageEventStore`; обновлены
`TokenUsageRecorderTests`/`GrpcAi*Tests`/`PipelineWorkerGrpcAiTests`/`GrpcMlClientTests`/ `TokenUsageRecorderTests`/`GrpcAi*Tests`/`PipelineWorkerGrpcAiTests`/`GrpcMlClientTests`/
`MlOutboxFlushSchedulerTests`/`IntegrationsDiTests` под новые зависимости. `MlOutboxFlushSchedulerTests`/`IntegrationsDiTests` под новые зависимости.
## Замечания / что осталось ## Замечания / что осталось
- Трансляция `groupBy=day` (`DateTimeOffset.Year/Month/Day``date_part`) проверена сборкой и - Трансляция `groupBy=day` (`DateTimeOffset.Year/Month/Day``date_part`) проверена сборкой и
фейк-хранилищем; живая проверка SQL-плана — при поднятом Postgres (Docker). фейк-хранилищем; живая проверка SQL-плана — при поднятом Postgres (Docker).
- `channel_created` не эмитится (нет ручки создания канала) — задокументировано как резерв каталога. - `channel_created` не эмитится (нет ручки создания канала) — задокументировано как резерв каталога.
@@ -1,42 +1,42 @@
# Task B2 report — аналитика оператора (T3) # Task B2 report — аналитика оператора (T3)
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`. Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`.
## Эндпоинты ## Эндпоинты
`Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs` — группа `/api/operator/analytics` (операторская сессия, `Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs` — группа `/api/operator/analytics` (операторская сессия,
read-only, 401 «Требуется вход оператора» без сессии): read-only, 401 «Требуется вход оператора» без сессии):
- `GET /overview` — тенанты всего/активных, расход токенов за период, число событий, - `GET /overview` — тенанты всего/активных, расход токенов за период, число событий,
входы/выходы/неудачные входы. входы/выходы/неудачные входы.
- `GET /tokens?groupBy=day|tenant|provider|model&tenantId=&from=&to=` — серия/агрегаты токенов + `total`. - `GET /tokens?groupBy=day|tenant|provider|model&tenantId=&from=&to=` — серия/агрегаты токенов + `total`.
- `GET /activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` — лента действий - `GET /activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` — лента действий
(`items`/`total`/`limit`/`offset`). (`items`/`total`/`limit`/`offset`).
Расширен `GET /api/operator/audit` (`OperatorAuditEndpoints`): фильтр `actorId`, `offset`; ответ Расширен `GET /api/operator/audit` (`OperatorAuditEndpoints`): фильтр `actorId`, `offset`; ответ
`{items, total}` обратно совместим (добавлены только query-параметры, поля ответа не менялись). `{items, total}` обратно совместим (добавлены только query-параметры, поля ответа не менялись).
## Прикладной слой ## Прикладной слой
- `Deal.Modules.Tenants/Application/AnalyticsService.cs` (scoped): `OverviewAsync`, `TokensAsync`, - `Deal.Modules.Tenants/Application/AnalyticsService.cs` (scoped): `OverviewAsync`, `TokensAsync`,
`ActivityAsync`, `NormalizeActivityLimit` (дефолт 100, кламп 1..500). `ActivityAsync`, `NormalizeActivityLimit` (дефолт 100, кламп 1..500).
Источники: `ITenantRepository`, `AuditService`, `TokenUsageEventService`. Источники: `ITenantRepository`, `AuditService`, `TokenUsageEventService`.
- DTO: `AnalyticsOverviewDto`, `AnalyticsTokensDto`, `AnalyticsActivityDto` - DTO: `AnalyticsOverviewDto`, `AnalyticsTokensDto`, `AnalyticsActivityDto`
(camelCase на wire, времена — ISO-8601). (camelCase на wire, времена — ISO-8601).
- `AuditQueryDto` расширен `ActorId` и `Offset` (опциональные, в конце — обратная совместимость). - `AuditQueryDto` расширен `ActorId` и `Offset` (опциональные, в конце — обратная совместимость).
`AuditLogStore`/`FakeAuditLogStore`: фильтр `ActorId` и `Skip(Offset)`; `CountAsync` — без offset. `AuditLogStore`/`FakeAuditLogStore`: фильтр `ActorId` и `Skip(Offset)`; `CountAsync` — без offset.
- Регистрация: `AnalyticsService` — в `AddTenantsModule`; эндпоинт замаплен в `Program.cs`. - Регистрация: `AnalyticsService` — в `AddTenantsModule`; эндпоинт замаплен в `Program.cs`.
## Контракт для фронта ## Контракт для фронта
`docs/architecture/2026-09-10-operator-analytics-contract.md` — эндпоинты, query-параметры, JSON-ответы `docs/architecture/2026-09-10-operator-analytics-contract.md` — эндпоинты, query-параметры, JSON-ответы
(camelCase), коды ошибок, каталог типов событий аудита (T1), семантика `groupBy`/`key`. (camelCase), коды ошибок, каталог типов событий аудита (T1), семантика `groupBy`/`key`.
## Валидация ## Валидация
- `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors. - `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors.
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные. - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные.
- Новые тесты: `OperatorAnalyticsEndpointsHttpTests` (401, overview, tokens+400 на группировку, - Новые тесты: `OperatorAnalyticsEndpointsHttpTests` (401, overview, tokens+400 на группировку,
activity с actorId/offset, `audit` с actorId/offset), `OperatorAuditEndpointsHelpersTests.NormalizeOffset`. activity с actorId/offset, `audit` с actorId/offset), `OperatorAuditEndpointsHelpersTests.NormalizeOffset`.
`OperatorAuthHttpHost` расширен фейком `ITokenUsageEventStore` и мэппингом аналитики. `OperatorAuthHttpHost` расширен фейком `ITokenUsageEventStore` и мэппингом аналитики.
## Замечания ## Замечания
- `overview` без `tenantId`: расход токенов агрегируется по всем тенантам за период - `overview` без `tenantId`: расход токенов агрегируется по всем тенантам за период
(модуль суммирует строки агрегата по тенантам). (модуль суммирует строки агрегата по тенантам).
- Времена в аналитике/аудите — ISO-8601 (как уже принято в `/api/operator/audit`), а не epoch-ms - Времена в аналитике/аудите — ISO-8601 (как уже принято в `/api/operator/audit`), а не epoch-ms
из `/api/cards`: зафиксировано в контрактном документе. из `/api/cards`: зафиксировано в контрактном документе.
@@ -1,95 +1,95 @@
# T4 (F1) — Оператор-консоль (фронт) # T4 (F1) — Оператор-консоль (фронт)
> Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`. > Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`.
> Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`; формы ручек сверены с кодом > Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`; формы ручек сверены с кодом
> `src/core/Deal.Api/Endpoints/Operator*Endpoints.cs`, `JoinEndpoint.cs` и request/DTO-моделями. > `src/core/Deal.Api/Endpoints/Operator*Endpoints.cs`, `JoinEndpoint.cs` и request/DTO-моделями.
## Что сделано ## Что сделано
### 1. Hash-роутинг без новых зависимостей ### 1. Hash-роутинг без новых зависимостей
`src/router.js` — крошечный слой над `window.location.hash`: `src/router.js` — крошечный слой над `window.location.hash`:
| Hash | Экран | | Hash | Экран |
|---|---| |---|---|
| `#/` (или пусто) | основное приложение (`views/MainApp.vue`) — как раньше | | `#/` (или пусто) | основное приложение (`views/MainApp.vue`) — как раньше |
| `#/operator` / `#/operator/<section>` | консоль оператора (`views/operator/OperatorConsole.vue`) | | `#/operator` / `#/operator/<section>` | консоль оператора (`views/operator/OperatorConsole.vue`) |
| `#/join?code=…` | активация инвайта (`views/JoinView.vue`) | | `#/join?code=…` | активация инвайта (`views/JoinView.vue`) |
- Реактивный `route` (`path` + `query`), `routeName`, `operatorSection`; подписка на `hashchange`; - Реактивный `route` (`path` + `query`), `routeName`, `operatorSection`; подписка на `hashchange`;
`navigate(to)` и `joinLink(code)` (абсолютная ссылка активации для копирования). `navigate(to)` и `joinLink(code)` (абсолютная ссылка активации для копирования).
- `initRouter()` вызывается в `main.js` **до** `createApp().mount()` — первый рендер сразу попадает на нужный - `initRouter()` вызывается в `main.js` **до** `createApp().mount()` — первый рендер сразу попадает на нужный
экран, без мигания основного приложения. экран, без мигания основного приложения.
- Прежнее содержимое `App.vue` без изменений вынесено в `views/MainApp.vue`; `App.vue` — тонкая оболочка. - Прежнее содержимое `App.vue` без изменений вынесено в `views/MainApp.vue`; `App.vue` — тонкая оболочка.
Оператор и join грузятся ленивыми чанками (`defineAsyncComponent`). Основное приложение не регрессирует: Оператор и join грузятся ленивыми чанками (`defineAsyncComponent`). Основное приложение не регрессирует:
`#/` работает ровно как раньше (проверено сборкой и сохранением кода 1:1). `#/` работает ровно как раньше (проверено сборкой и сохранением кода 1:1).
### 2. Экраны оператора ### 2. Экраны оператора
`views/operator/OperatorConsole.vue` — каркас: проверка сессии (`GET /api/operator/auth/me`), левое меню `views/operator/OperatorConsole.vue` — каркас: проверка сессии (`GET /api/operator/auth/me`), левое меню
разделов, переключение по `#/operator/<section>`, выход (`/api/operator/auth/logout`), ссылка в приложение. разделов, переключение по `#/operator/<section>`, выход (`/api/operator/auth/logout`), ссылка в приложение.
Глобальные `Toasts` и `ConfirmDialog` (из основного store) подключены на всех экранах консоли, включая вход. Глобальные `Toasts` и `ConfirmDialog` (из основного store) подключены на всех экранах консоли, включая вход.
| Раздел | Ручки (сверено с кодом) | | Раздел | Ручки (сверено с кодом) |
|---|---| |---|---|
| Вход | `POST /api/operator/auth/login`, `GET /me`, `POST /logout` | | Вход | `POST /api/operator/auth/login`, `GET /me`, `POST /logout` |
| Тенанты | `GET/POST /api/operator/tenants`, `GET /{id}`, `POST /{id}/suspend|unsuspend|impersonate` | | Тенанты | `GET/POST /api/operator/tenants`, `GET /{id}`, `POST /{id}/suspend|unsuspend|impersonate` |
| Приглашения | `GET/POST /api/operator/invites`, `POST /{code}/revoke` (+ копирование `#/join?code=…`) | | Приглашения | `GET/POST /api/operator/invites`, `POST /{code}/revoke` (+ копирование `#/join?code=…`) |
| Лимиты | `GET /api/operator/limits`, `GET/PATCH /api/operator/tenants/{id}/limit` | | Лимиты | `GET /api/operator/limits`, `GET/PATCH /api/operator/tenants/{id}/limit` |
| Аудит | `GET /api/operator/audit` (eventType/actorType/actorId/tenantId/from/to/limit/offset, total) | | Аудит | `GET /api/operator/audit` (eventType/actorType/actorId/tenantId/from/to/limit/offset, total) |
| Аналитика | `GET /api/operator/analytics/{overview,tokens,activity}` | | Аналитика | `GET /api/operator/analytics/{overview,tokens,activity}` |
| Состояние | `GET /api/operator/health` | | Состояние | `GET /api/operator/health` |
Детали: Детали:
- **Тенанты**: список (name/status/usersCount/createdAt), создание (name + email владельца; при наличии email - **Тенанты**: список (name/status/usersCount/createdAt), создание (name + email владельца; при наличии email
показывается одноразовый пароль владельца — только в ответе), карточка тенанта с пользователями, suspend/ показывается одноразовый пароль владельца — только в ответе), карточка тенанта с пользователями, suspend/
resume (с подтверждением), impersonate по пользователю. resume (с подтверждением), impersonate по пользователю.
- **Приглашения**: статусы pending/activated/revoked/expired бейджами, создание (email + опциональный тенант), - **Приглашения**: статусы pending/activated/revoked/expired бейджами, создание (email + опциональный тенант),
отзыв только pending, копирование ссылки активации. отзыв только pending, копирование ссылки активации.
- **Лимиты**: сводка с прогресс-барами расхода, правка бюджета/периода (`PATCH`, форма предзаполняется `GET`). - **Лимиты**: сводка с прогресс-барами расхода, правка бюджета/периода (`PATCH`, форма предзаполняется `GET`).
- **Аудит/Действия**: фильтры в одну строку (событие/актор/tenantId/actorId/from/to), пагинация по `offset` - **Аудит/Действия**: фильтры в одну строку (событие/актор/tenantId/actorId/from/to), пагинация по `offset`
(`limit=50`), `total` из ответа, раскрытие `detailJson` (строка JSON → pretty). (`limit=50`), `total` из ответа, раскрытие `detailJson` (строка JSON → pretty).
- **Аналитика**: подразделы обзор (9 плиток метрик), токены (`groupBy=day|tenant|provider|model`, фильтр - **Аналитика**: подразделы обзор (9 плиток метрик), токены (`groupBy=day|tenant|provider|model`, фильтр
tenantId, Tailwind-бары + итог), действия (та же лента, что аудит). Внешних chart-библиотек нет. tenantId, Tailwind-бары + итог), действия (та же лента, что аудит). Внешних chart-библиотек нет.
- **Состояние**: `ok`, `core.db`, список сервисов с режимом/доступностью/статусом. - **Состояние**: `ok`, `core.db`, список сервисов с режимом/доступностью/статусом.
### 3. Разделение кода ### 3. Разделение кода
- **Store-слайс** `src/store/operator.js` — состояние `op` и все действия (thin: fetch → `op` + тост). - **Store-слайс** `src/store/operator.js` — состояние `op` и все действия (thin: fetch → `op` + тост).
Импортируется напрямую ленивым чанком (не через `store/index.js`), чтобы код оператора не попадал в Импортируется напрямую ленивым чанком (не через `store/index.js`), чтобы код оператора не попадал в
основной бандл. основной бандл.
- **Примитивы** `src/components/ui/`: `Button`, `TextInput`, `SelectInput`, `Card`, `StatCard`, `Badge`, - **Примитивы** `src/components/ui/`: `Button`, `TextInput`, `SelectInput`, `Card`, `StatCard`, `Badge`,
`DataTable`, `BarList`, `Pagination`. `DataTable`, `BarList`, `Pagination`.
- **Общие блоки оператора** `src/components/operator/`: `SectionLayout`, `AuditFilters`, `AuditTable` - **Общие блоки оператора** `src/components/operator/`: `SectionLayout`, `AuditFilters`, `AuditTable`
(переиспользуются разделами «Аудит» и «Аналитика → Действия» — без дублей). (переиспользуются разделами «Аудит» и «Аналитика → Действия» — без дублей).
## Изменённые/созданные файлы ## Изменённые/созданные файлы
- Создано: `src/router.js`; `src/views/MainApp.vue`; `src/views/JoinView.vue`; - Создано: `src/router.js`; `src/views/MainApp.vue`; `src/views/JoinView.vue`;
`src/views/operator/{OperatorConsole,OperatorLogin,TenantsSection,InvitesSection,LimitsSection,AuditSection,AnalyticsSection,HealthSection}.vue`; `src/views/operator/{OperatorConsole,OperatorLogin,TenantsSection,InvitesSection,LimitsSection,AuditSection,AnalyticsSection,HealthSection}.vue`;
`src/components/ui/{Button,TextInput,SelectInput,Card,StatCard,Badge,DataTable,BarList,Pagination}.vue`; `src/components/ui/{Button,TextInput,SelectInput,Card,StatCard,Badge,DataTable,BarList,Pagination}.vue`;
`src/components/operator/{SectionLayout,AuditFilters,AuditTable}.vue`; `src/store/operator.js`. `src/components/operator/{SectionLayout,AuditFilters,AuditTable}.vue`; `src/store/operator.js`.
- Изменено: `src/App.vue` (оболочка), `src/main.js` (initRouter), `src/api.js` (несколько 401-обработчиков с - Изменено: `src/App.vue` (оболочка), `src/main.js` (initRouter), `src/api.js` (несколько 401-обработчиков с
путём запроса), `src/store/session.js` (обработчик игнорирует `/api/operator/*` — сессии независимы). путём запроса), `src/store/session.js` (обработчик игнорирует `/api/operator/*` — сессии независимы).
## Проверка ## Проверка
- `cd src/frontend && npm run build`**зелёно** (103 модуля; ленивые чанки `OperatorConsole` ~54 КБ и - `cd src/frontend && npm run build`**зелёно** (103 модуля; ленивые чанки `OperatorConsole` ~54 КБ и
`JoinView` ~7 КБ). `JoinView` ~7 КБ).
## Расхождения с бэком (нужно учесть) ## Расхождения с бэком (нужно учесть)
1. **Impersonation не применяется в браузере.** `POST /{id}/impersonate` отдаёт `sessionToken`, который 1. **Impersonation не применяется в браузере.** `POST /{id}/impersonate` отдаёт `sessionToken`, который
«используется как значение куки `deal_session`» (комментарий в `OperatorTenantsEndpoints`). Но «используется как значение куки `deal_session`» (комментарий в `OperatorTenantsEndpoints`). Но
`AuthEndpoints.SetSessionCookie` ставит куку с `HttpOnly = true`, а completion-ручки (принять токен и `AuthEndpoints.SetSessionCookie` ставит куку с `HttpOnly = true`, а completion-ручки (принять токен и
выставить куку) нет. JS не может выставить httpOnly-куку → войти под пользователем из UI нельзя. выставить куку) нет. JS не может выставить httpOnly-куку → войти под пользователем из UI нельзя.
UI вызывает ручку, показывает логин и **токен с кнопкой копирования** и поясняет ограничение. UI вызывает ручку, показывает логин и **токен с кнопкой копирования** и поясняет ограничение.
Требуется доработка бэка: либо не-httpOnly кука для impersonation, либо ручка-«completion». Требуется доработка бэка: либо не-httpOnly кука для impersonation, либо ручка-«completion».
2. **`invite_activated` vs `invite_joined`.** В `AuditEvents` есть легаси `invite_activated`, но join пишет 2. **`invite_activated` vs `invite_joined`.** В `AuditEvents` есть легаси `invite_activated`, но join пишет
`invite_joined`; в каталоге контракта `invite_activated` не значится. В фильтр событий добавлены оба. `invite_joined`; в каталоге контракта `invite_activated` не значится. В фильтр событий добавлены оба.
3. **`GET /api/operator/audit`** отдаёт `{items,total}` без `limit/offset` (в отличие от 3. **`GET /api/operator/audit`** отдаёт `{items,total}` без `limit/offset` (в отличие от
`analytics/activity`). Пагинация считается по параметрам запроса — расхождение учтено. `analytics/activity`). Пагинация считается по параметрам запроса — расхождение учтено.
4. **`PATCH .../limit`**: пустое тело/без полей → `400`; форма всегда отправляет `budget` + `period`. 4. **`PATCH .../limit`**: пустое тело/без полей → `400`; форма всегда отправляет `budget` + `period`.
Идемпотентный повтор (те же значения) бэкенд принимает без аудита — поведение корректно. Идемпотентный повтор (те же значения) бэкенд принимает без аудита — поведение корректно.
5. Ответы `POST /tenants` включают `ownerEmail`/`initialPassword` **только** при создании с email — пароль 5. Ответы `POST /tenants` включают `ownerEmail`/`initialPassword` **только** при создании с email — пароль
показывается один раз в модальном окне. показывается один раз в модальном окне.
@@ -1,40 +1,40 @@
# T5 (F2) — Страница активации инвайта (фронт) # T5 (F2) — Страница активации инвайта (фронт)
> Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`. > Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`.
> Контракт ручки сверен с `src/core/Deal.Api/Endpoints/JoinEndpoint.cs` и `JoinRequest.cs`. > Контракт ручки сверен с `src/core/Deal.Api/Endpoints/JoinEndpoint.cs` и `JoinRequest.cs`.
## Что сделано ## Что сделано
`src/views/JoinView.vue` — экран `#/join?code=…` (ленивый чанк, подключён в `App.vue`). `src/views/JoinView.vue` — экран `#/join?code=…` (ленивый чанк, подключён в `App.vue`).
- Код берётся из query (`route.query.code`), реактивно — переживает `hashchange`. - Код берётся из query (`route.query.code`), реактивно — переживает `hashchange`.
- Форма: **email**, **имя пространства** (опционально), **пароль** (клиентская проверка ≥ 8, до отправки). - Форма: **email**, **имя пространства** (опционально), **пароль** (клиентская проверка ≥ 8, до отправки).
- Отправка: `POST /api/join` с телом `{ code, email, name: string|null, password }`. - Отправка: `POST /api/join` с телом `{ code, email, name: string|null, password }`.
Кука не ставится (как и в контракте) — после успеха пользователь входит обычным логином. Кука не ставится (как и в контракте) — после успеха пользователь входит обычным логином.
- **Обработка ошибок контракта** — по фиксированным `detail` из `JoinEndpoint.DetailFor`: - **Обработка ошибок контракта** — по фиксированным `detail` из `JoinEndpoint.DetailFor`:
- «Приглашение не найдено», «Срок действия приглашения истёк», «Приглашение уже использовано», - «Приглашение не найдено», «Срок действия приглашения истёк», «Приглашение уже использовано»,
«Приглашение отозвано», «Email не совпадает с приглашением», «Этот email уже зарегистрирован», «Приглашение отозвано», «Email не совпадает с приглашением», «Этот email уже зарегистрирован»,
«Пароль слишком короткий (минимум 8 символов)», «Тенант приглашения не найден», «Пароль слишком короткий (минимум 8 символов)», «Тенант приглашения не найден»,
«Тенант приглашения приостановлен». «Тенант приглашения приостановлен».
- Текст `detail` показывается как есть; для известных причин добавляется короткая подсказка (что делать). - Текст `detail` показывается как есть; для известных причин добавляется короткая подсказка (что делать).
- Сетевые/неизвестные сбои — общее сообщение. - Сетевые/неизвестные сбои — общее сообщение.
- **Отдельные состояния экрана**: - **Отдельные состояния экрана**:
- нет `code` в ссылке → «Ссылка неполная» + возврат ко входу; - нет `code` в ссылке → «Ссылка неполная» + возврат ко входу;
- успех → «Аккаунт активирован» + кнопка «Перейти ко входу» (`navigate('/')`). - успех → «Аккаунт активирован» + кнопка «Перейти ко входу» (`navigate('/')`).
- Стиль — как у пользовательского входа (карточка, `radar-grid`, бренд-градиент), переиспользован `Icon`. - Стиль — как у пользовательского входа (карточка, `radar-grid`, бренд-градиент), переиспользован `Icon`.
## Файлы ## Файлы
- Создано: `src/views/JoinView.vue`. - Создано: `src/views/JoinView.vue`.
- Связано: `src/router.js` (`joinLink` формирует эту ссылку в консоли оператора — раздел «Приглашения»). - Связано: `src/router.js` (`joinLink` формирует эту ссылку в консоли оператора — раздел «Приглашения»).
## Проверка ## Проверка
- `cd src/frontend && npm run build`**зелёно** (отдельный чанк `JoinView`). - `cd src/frontend && npm run build`**зелёно** (отдельный чанк `JoinView`).
## Расхождения/замечания ## Расхождения/замечания
- Ручка возвращает `400 {detail}` на все отказы активации, включая просроченный инвайт (в коде — «410-семантика»), - Ручка возвращает `400 {detail}` на все отказы активации, включая просроченный инвайт (в коде — «410-семантика»),
поэтому фронт не различает 400/410, а опирается на текст `detail`. Это соответствует коду эндпоинта. поэтому фронт не различает 400/410, а опирается на текст `detail`. Это соответствует коду эндпоинта.
- `name` обязан быть `null`/пустым для инвайтов с целевым тенантом (имя берётся у тенанта); UI отправляет - `name` обязан быть `null`/пустым для инвайтов с целевым тенантом (имя берётся у тенанта); UI отправляет
`name: null`, если поле пустое — бэкенд это принимает (`JoinRequest.Name` опционален). `name: null`, если поле пустое — бэкенд это принимает (`JoinRequest.Name` опционален).
+27 -27
View File
@@ -1,27 +1,27 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md # SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Проект НЕ git: фиксация — отчёты задач и этот ledger.
## Решение владельца (2026-09-10) ## Решение владельца (2026-09-10)
Только русский; переключатель языка и второй язык на этом этапе НЕ делались — оставлены в бэклоге Только русский; переключатель языка и второй язык на этом этапе НЕ делались — оставлены в бэклоге
(делаем при появлении потребности). Задача — вынести строки в (делаем при появлении потребности). Задача — вынести строки в
ресурсы (архитектура готова к добавлению языка позже). Визуал/тексты — 1:1. ресурсы (архитектура готова к добавлению языка позже). Визуал/тексты — 1:1.
## Todos ## Todos
- [x] T1: i18n-ядро (t/locale/словари, фолбэк ru) - [x] T1: i18n-ядро (t/locale/словари, фолбэк ru)
- [x] T2: Словарь ru (инвентаризация по областям) - [x] T2: Словарь ru (инвентаризация по областям)
- [x] T3: Миграция основного приложения на t() - [x] T3: Миграция основного приложения на t()
- [x] T4: Миграция оператор-консоли и /join - [x] T4: Миграция оператор-консоли и /join
- [x] T5: Локализация ошибок/статусов - [x] T5: Локализация ошибок/статусов
- [x] T6: Проверки (линтер кириллицы вне словарей, build) - [x] T6: Проверки (линтер кириллицы вне словарей, build)
- [x] T7: Доки и STATUS - [x] T7: Доки и STATUS
## В бэклоге (делаем при появлении потребности) ## В бэклоге (делаем при появлении потребности)
- Переключатель языка в UI и второй язык. - Переключатель языка в UI и второй язык.
- Форматтеры Intl/плюрализация — вместе с языком. - Форматтеры Intl/плюрализация — вместе с языком.
## Task status ## Task status
- 2026-09-10: этап 11 выполнен (урезанный объём). Ядро `src/frontend/src/i18n/` (index.js, locales/ru.js, - 2026-09-10: этап 11 выполнен (урезанный объём). Ядро `src/frontend/src/i18n/` (index.js, locales/ru.js,
locales/ru.data.js, errors.js); строки вынесены: 1039 ключей в 13 областях, 1209 ссылок `t()/$t()` в 65 locales/ru.data.js, errors.js); строки вынесены: 1039 ключей в 13 областях, 1209 ссылок `t()/$t()` в 65
файлах. `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Временные кодимод-скрипты удалены. файлах. `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Временные кодимод-скрипты удалены.
Переключателя языка в UI нет (по решению владельца). См. task-report.md. Переключателя языка в UI нет (по решению владельца). См. task-report.md.
@@ -1,80 +1,80 @@
# Task report — Дейл, этап 11: локализация (i18n, урезанный объём) # Task report — Дейл, этап 11: локализация (i18n, урезанный объём)
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md`. Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md`.
## Итог ## Итог
Все пользовательские строки фронтенда вынесены из компонентов/вьюх/store в словари-ресурсы. Все пользовательские строки фронтенда вынесены из компонентов/вьюх/store в словари-ресурсы.
Язык один — русский; **переключателя языка в UI нет и второго языка нет** (решение владельца). Язык один — русский; **переключателя языка в UI нет и второго языка нет** (решение владельца).
Архитектура готова к добавлению языка позже без правок компонентов. Отображаемые тексты — 1:1. Архитектура готова к добавлению языка позже без правок компонентов. Отображаемые тексты — 1:1.
- `npm run build`**зелёный** (vite build, `✓ built`). - `npm run build`**зелёный** (vite build, `✓ built`).
- `npm run lint:i18n`**зелёный** («кириллических пользовательских строк вне словарей не найдено»). - `npm run lint:i18n`**зелёный** («кириллических пользовательских строк вне словарей не найдено»).
- Словарь: **1039 ключей** в **13 областях**; **1209 ссылок** `t()/$t()` в **65** файлах `src/**/*.{vue,js}`. - Словарь: **1039 ключей** в **13 областях**; **1209 ссылок** `t()/$t()` в **65** файлах `src/**/*.{vue,js}`.
## Структура `src/frontend/src/i18n/` ## Структура `src/frontend/src/i18n/`
``` ```
src/i18n/ src/i18n/
index.js — ядро: t(key, params), реактивный locale (default ru), index.js — ядро: t(key, params), реактивный locale (default ru),
setLocale(), registerLocale(), availableLocales(), useI18n(), setLocale(), registerLocale(), availableLocales(), useI18n(),
Vue-плагин ($t в шаблонах). Фолбэк: активный язык → ru → сам ключ. Vue-плагин ($t в шаблонах). Фолбэк: активный язык → ru → сам ключ.
errors.js — localizeError(err, fallbackKey): HTTP-статусы/сеть → ключи errors/*, errors.js — localizeError(err, fallbackKey): HTTP-статусы/сеть → ключи errors/*,
осмысленный {detail} бэка и неизвестное — как есть. осмысленный {detail} бэка и неизвестное — как есть.
locales/ locales/
ru.js — словарь сообщений (единственный источник текстов). ru.js — словарь сообщений (единственный источник текстов).
ru.data.js — языковой контент-каталог (валюты, AI-провайдеры, дефолтные ru.data.js — языковой контент-каталог (валюты, AI-провайдеры, дефолтные
промпты, категории/шаблоны промптов), реэкспортируется из src/data.js. промпты, категории/шаблоны промптов), реэкспортируется из src/data.js.
``` ```
Подключение: `main.js``createApp(App).use(i18n)`. Алиас Vite `@ → src` (без новых зависимостей) Подключение: `main.js``createApp(App).use(i18n)`. Алиас Vite `@ → src` (без новых зависимостей)
для единого импорта `@/i18n/index.js`. для единого импорта `@/i18n/index.js`.
## Области ключей (`ru.js`) ## Области ключей (`ru.js`)
`common/` `nav/` `search/` `cards/` `drawer/` `columns/` `settings/` `channels/` `processing/` `common/` `nav/` `search/` `cards/` `drawer/` `columns/` `settings/` `channels/` `processing/`
`auth/` `operator/` `join/` `errors/` (+ вложенные подразделы). Разбивка по количеству ключей: `auth/` `operator/` `join/` `errors/` (+ вложенные подразделы). Разбивка по количеству ключей:
settings 260, common 156, channels 146, operator 144, cards 75, processing 74, drawer 56, settings 260, common 156, channels 146, operator 144, cards 75, processing 74, drawer 56,
columns 48, nav 30, join 28, auth 7, search 2, errors 13. columns 48, nav 30, join 28, auth 7, search 2, errors 13.
## Что сделано по задачам ## Что сделано по задачам
- **T1. Ядро i18n** — `src/i18n/index.js` без внешних зависимостей: `t(key, params)` с подстановкой - **T1. Ядро i18n** — `src/i18n/index.js` без внешних зависимостей: `t(key, params)` с подстановкой
`{name}`, реактивный `locale` (default `ru`), `setLocale()` (готов, UI нет), `registerLocale()`, `{name}`, реактивный `locale` (default `ru`), `setLocale()` (готов, UI нет), `registerLocale()`,
загрузка словарей, фолбэк на `ru`. Плагин даёт шаблонам `$t(...)`. загрузка словарей, фолбэк на `ru`. Плагин даёт шаблонам `$t(...)`.
- **T2. Словарь ru** — инвентаризация строк по областям; значения 1:1 с исходными. Языковой контент - **T2. Словарь ru** — инвентаризация строк по областям; значения 1:1 с исходными. Языковой контент
из `src/data.js` (валюты, провайдеры, промпты, категории) перенесён в `locales/ru.data.js`. из `src/data.js` (валюты, провайдеры, промпты, категории) перенесён в `locales/ru.data.js`.
- **T3. Миграция основного приложения** — компоненты, вьюхи и store-слайсы (`store/*.js`) переведены - **T3. Миграция основного приложения** — компоненты, вьюхи и store-слайсы (`store/*.js`) переведены
на `t()`/`$t()`; строки в store тоже вынесены. на `t()`/`$t()`; строки в store тоже вынесены.
- **T4. Оператор-консоль и `/join`** — все разделы консоли и `JoinView` мигрированы. - **T4. Оператор-консоль и `/join`** — все разделы консоли и `JoinView` мигрированы.
- **T5. Ошибки/статусы** — `i18n/errors.js` маппит известные HTTP-статусы и сетевые сбои на `errors/*`; - **T5. Ошибки/статусы** — `i18n/errors.js` маппит известные HTTP-статусы и сетевые сбои на `errors/*`;
точка применения — `errMsg` в `store/core.js`. Неизвестный текст бэка показывается как есть. точка применения — `errMsg` в `store/core.js`. Неизвестный текст бэка показывается как есть.
- **T6. Проверки** — `scripts/i18n-lint.mjs` + `npm run lint:i18n`: падает на кириллицу в пользовательских - **T6. Проверки** — `scripts/i18n-lint.mjs` + `npm run lint:i18n`: падает на кириллицу в пользовательских
строках вне `src/i18n/locales/**`; корректно пропускает JS/HTML-комментарии, regex-литералы и строки строках вне `src/i18n/locales/**`; корректно пропускает JS/HTML-комментарии, regex-литералы и строки
с директивой `i18n-ignore`. `npm run build` — зелёный. с директивой `i18n-ignore`. `npm run build` — зелёный.
- **T7. Доки** — обновлены `docs/user-guide/Инструкция-пользователя-Дейл.md` (раздел «Язык интерфейса», - **T7. Доки** — обновлены `docs/user-guide/Инструкция-пользователя-Дейл.md` (раздел «Язык интерфейса»,
без упоминания переключателя) и `docs/technical/Техническая-документация-Дейл.md` (раздел 14: устройство без упоминания переключателя) и `docs/technical/Техническая-документация-Дейл.md` (раздел 14: устройство
i18n и пошаговая инструкция «как добавить язык позже»). Это единственные правки вне `src/frontend`. i18n и пошаговая инструкция «как добавить язык позже»). Это единственные правки вне `src/frontend`.
## Ключевые решения и нюансы ## Ключевые решения и нюансы
- **Технические строки не локализованы**: dev-лог в `Icon.vue` и служебные значения помечены - **Технические строки не локализованы**: dev-лог в `Icon.vue` и служебные значения помечены
`i18n-ignore`; тексты причин отказа бэкенда в `JoinView` оставлены как данные-ключи карты подсказок. `i18n-ignore`; тексты причин отказа бэкенда в `JoinView` оставлены как данные-ключи карты подсказок.
- **Плюрализация не вводилась** (отложено): множественные формы по-прежнему выбираются в коде - **Плюрализация не вводилась** (отложено): множественные формы по-прежнему выбираются в коде
(`... === 1 ? 'файл' : 'файлов'`), но уже через ключи словаря. (`... === 1 ? 'файл' : 'файлов'`), но уже через ключи словаря.
- **Дубли значений устранены** переназначением ссылок (общие строки — в `common/`). - **Дубли значений устранены** переназначением ссылок (общие строки — в `common/`).
Единственное «дублирование» — `errors.badGateway`/`errors.network` (разные по смыслу статусы). Единственное «дублирование» — `errors.badGateway`/`errors.network` (разные по смыслу статусы).
## Осталось / отложено (по решению владельца) ## Осталось / отложено (по решению владельца)
- Переключатель языка в UI и второй язык (en) — по запросу. - Переключатель языка в UI и второй язык (en) — по запросу.
- Перевод дат/чисел/валют на `Intl` и плюрализация — вместе с будущим языком. - Перевод дат/чисел/валют на `Intl` и плюрализация — вместе с будущим языком.
- Константы, вычисляемые один раз при загрузке модуля (контент-каталог `ru.data.js`, карты подсказок), - Константы, вычисляемые один раз при загрузке модуля (контент-каталог `ru.data.js`, карты подсказок),
держат язык, выбранный на старте; для полноценного «горячего» переключения их потребуется обернуть держат язык, выбранный на старте; для полноценного «горячего» переключения их потребуется обернуть
в `computed`. На текущем этапе (один язык) поведение идентично прежнему. в `computed`. На текущем этапе (один язык) поведение идентично прежнему.
## Валидация ## Валидация
- `cd C:\telbase\src\frontend && npm run build``✓ built in ~1.2s` (единственное замечание — предупреждение - `cd C:\telbase\src\frontend && npm run build``✓ built in ~1.2s` (единственное замечание — предупреждение
Vite о размере чанка >500 kB; словарь расширил бандл, на сборку не влияет). Vite о размере чанка >500 kB; словарь расширил бандл, на сборку не влияет).
- `npm run lint:i18n``✓ i18n: кириллических пользовательских строк вне словарей не найдено.` - `npm run lint:i18n``✓ i18n: кириллических пользовательских строк вне словарей не найдено.`
@@ -1,238 +1,238 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md # SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Проект НЕ git: фиксация — отчёты задач и этот ledger.
## Автономный заход (без кредов и решений владельца) ## Автономный заход (без кредов и решений владельца)
## Todos ## Todos
- [x] A: Метрики (Prometheus + Grafana, /metrics, обвязка сервисов) — task-a-report.md - [x] A: Метрики (Prometheus + Grafana, /metrics, обвязка сервисов) — task-a-report.md
- [x] B: Распределённый rate-limit, LoginAttemptGuard на Postgres, разлогин suspended, purge audit_log/tenant_limits — task-b-report.md - [x] B: Распределённый rate-limit, LoginAttemptGuard на Postgres, разлогин suspended, purge audit_log/tenant_limits — task-b-report.md
- [x] C: Перф (i18n-чанк, виртуализация колонок, LRU WTelegram, миграции 1000 схем, lint в test.sh) - [x] C: Перф (i18n-чанк, виртуализация колонок, LRU WTelegram, миграции 1000 схем, lint в test.sh)
- [x] C1 (фронт): разбиение бандла + прогрессивный рендер колонок + `lint:i18n` — task-c1-report.md - [x] C1 (фронт): разбиение бандла + прогрессивный рендер колонок + `lint:i18n` — task-c1-report.md
- [x] C2 (бэк/сервисы/скрипты): LRU WTelegram, пакетная миграция схем тенантов, `lint:i18n` в test.sh — task-c2-report.md - [x] C2 (бэк/сервисы/скрипты): LRU WTelegram, пакетная миграция схем тенантов, `lint:i18n` в test.sh — task-c2-report.md
- [x] D: reclassify-проводка + токены ML — task-d-report.md - [x] D: reclassify-проводка + токены ML — task-d-report.md
- [x] E: Остатки этапа 12 (единый `TestPort` без гонки портов, SSE `cards_reclassified`) — task-e-report.md - [x] E: Остатки этапа 12 (единый `TestPort` без гонки портов, SSE `cards_reclassified`) — task-e-report.md
## Task status ## Task status
- 2026-09-10: A (метрики) — выполнено. OTel → Prometheus во всех 4 процессах, /metrics на отдельном - 2026-09-10: A (метрики) — выполнено. OTel → Prometheus во всех 4 процессах, /metrics на отдельном
HTTP/1.1-порту :9464, общий хелпер в Deal.Grpc.Hosting + зеркало в Deal.Api, прикладные метрики HTTP/1.1-порту :9464, общий хелпер в Deal.Grpc.Hosting + зеркало в Deal.Api, прикладные метрики
(AI/ML токены/вызовы, аудит, очереди, сессии), Prometheus в профиле observability (prod+dev), (AI/ML токены/вызовы, аудит, очереди, сессии), Prometheus в профиле observability (prod+dev),
Grafana datasource + Deal-Metrics-Overview, техдок §7. Build 0/0 всех 4 sln; core/ai/ml/telegram Grafana datasource + Deal-Metrics-Overview, техдок §7. Build 0/0 всех 4 sln; core/ai/ml/telegram
тесты PASS; compose config rc=0 (prod+dev); живой прогон dev-стека: /metrics всех 4 процессов OK, тесты PASS; compose config rc=0 (prod+dev); живой прогон dev-стека: /metrics всех 4 процессов OK,
Prometheus scrape — 5/5 UP; стек погашен. Детали — task-a-report.md. Prometheus scrape — 5/5 UP; стек погашен. Детали — task-a-report.md.
- 2026-09-10: фикс регрессии этапа 11 — `PromptDefaultsTests` читали перенесённый `data.js`; переведены - 2026-09-10: фикс регрессии этапа 11 — `PromptDefaultsTests` читали перенесённый `data.js`; переведены
на `src/frontend/src/i18n/locales/ru.data.js`. Core-тесты снова **1173/1173 PASS** (тот блок «3 падения» — на `src/frontend/src/i18n/locales/ru.data.js`. Core-тесты снова **1173/1173 PASS** (тот блок «3 падения» —
это и был этот тест, а не пакет C). это и был этот тест, а не пакет C).
- 2026-09-10: B (устойчивость/безопасность) — выполнено. Распределённый rate-limit на Postgres - 2026-09-10: B (устойчивость/безопасность) — выполнено. Распределённый rate-limit на Postgres
(таблица `public.rate_limit_counters`, атомарный upsert) вместо in-memory: HTTP-политики auth/api/global (таблица `public.rate_limit_counters`, атомарный upsert) вместо in-memory: HTTP-политики auth/api/global
и gRPC-ингресс переведены на store-backed лимитер; `LoginAttemptGuard` — на то же хранилище (async); и gRPC-ингресс переведены на store-backed лимитер; `LoginAttemptGuard` — на то же хранилище (async);
мгновенный разлогин suspended-сессий (проверка статуса тенанта в `AuthService.ResolveSessionAsync`, мгновенный разлогин suspended-сессий (проверка статуса тенанта в `AuthService.ResolveSessionAsync`,
включая impersonation); фоновый `DataRetentionScheduler` (purge audit_log по retention 180 дней, включая impersonation); фоновый `DataRetentionScheduler` (purge audit_log по retention 180 дней,
сброс накопительных полей tenant_limits прошедших периодов, уборка окон счётчиков). Системная сброс накопительных полей tenant_limits прошедших периодов, уборка окон счётчиков). Системная
миграция `RateLimitCounters`. Build Deal.sln 0/0; core-тесты **1184/1184 PASS** (+11). Детали — task-b-report.md. миграция `RateLimitCounters`. Build Deal.sln 0/0; core-тесты **1184/1184 PASS** (+11). Детали — task-b-report.md.
- 2026-09-10: C1 (фронт-перф) — выполнено. `vite.config.js`: `manualChunks``vendor` (vue/node_modules), - 2026-09-10: C1 (фронт-перф) — выполнено. `vite.config.js`: `manualChunks``vendor` (vue/node_modules),
`i18n` (словари локалей), `app`; крупнейший чанк 501.74 kB → **309.02 kB**, предупреждение >500 kB ушло, `i18n` (словари локалей), `app`; крупнейший чанк 501.74 kB → **309.02 kB**, предупреждение >500 kB ушло,
`chunkSizeWarningLimit` не повышался. Прогрессивный рендер длинных колонок через примитив `chunkSizeWarningLimit` не повышался. Прогрессивный рендер длинных колонок через примитив
`useProgressiveList` (`src/composables/progressive.js`): первые 100 карточек + кнопка «Показать ещё», `useProgressiveList` (`src/composables/progressive.js`): первые 100 карточек + кнопка «Показать ещё»,
подключён в `ContainerColumn.vue` (дашборд и «Выбранные»); на малых списках вид 1:1, новых зависимостей нет. подключён в `ContainerColumn.vue` (дашборд и «Выбранные»); на малых списках вид 1:1, новых зависимостей нет.
`npm run lint:i18n` зелёный, описан в новом `src/frontend/README.md`. В `npm run build` линтер НЕ добавлен. `npm run lint:i18n` зелёный, описан в новом `src/frontend/README.md`. В `npm run build` линтер НЕ добавлен.
Детали — task-c1-report.md. Детали — task-c1-report.md.
- 2026-09-10: C — остальные подпункты (LRU WTelegram, миграции на 1000 схем, `lint:i18n` в `scripts/test.sh`) - 2026-09-10: C — остальные подпункты (LRU WTelegram, миграции на 1000 схем, `lint:i18n` в `scripts/test.sh`)
вне фронта; переданы отдельным агентам, каталог src/frontend не затронут. вне фронта; переданы отдельным агентам, каталог src/frontend не затронут.
- 2026-09-10: C2 (бэк/сервисы/скрипты) — выполнено. telegram-service: неограниченные словари - 2026-09-10: C2 (бэк/сервисы/скрипты) — выполнено. telegram-service: неограниченные словари
WTelegram-клиента (`_entityAccessHashes`/`_chatsById`/`_usersById`) переведены на новый WTelegram-клиента (`_entityAccessHashes`/`_chatsById`/`_usersById`) переведены на новый
`LruCache<TKey,TValue>` (`Caching/LruCache.cs`) с именованными ёмкостями 2000/1000/2000; `LruCache<TKey,TValue>` (`Caching/LruCache.cs`) с именованными ёмкостями 2000/1000/2000;
поведение сохранено (вытеснение → прежние фолбэки). Core: идемпотентность проважининга поведение сохранено (вытеснение → прежние фолбэки). Core: идемпотентность проважининга
подтверждена (EF Migrate применяет только неприменённые миграции); добавлен подтверждена (EF Migrate применяет только неприменённые миграции); добавлен
`TenantSchemaMigrationService` — пакетная миграция всех схем реестра (`Parallel.ForEachAsync`, `TenantSchemaMigrationService` — пакетная миграция всех схем реестра (`Parallel.ForEachAsync`,
clamp [1..32], прогресс-лог, сбой одной не прерывает остальные) + сводка `TenantMigrationSummary`, clamp [1..32], прогресс-лог, сбой одной не прерывает остальные) + сводка `TenantMigrationSummary`,
операторская ручка `POST /api/operator/maintenance/tenants/migrate`; `TenantBootstrapService` операторская ручка `POST /api/operator/maintenance/tenants/migrate`; `TenantBootstrapService`
переведён на тот же сервис (параллельно + логи); bootstrap не сломан. `scripts/test.sh` теперь переведён на тот же сервис (параллельно + логи); bootstrap не сломан. `scripts/test.sh` теперь
после core-тестов гоняет `npm run lint:i18n` (нет npm — мягкий пропуск). Telegram-тесты после core-тестов гоняет `npm run lint:i18n` (нет npm — мягкий пропуск). Telegram-тесты
**125/125 PASS** (+6), core-тесты **1189/1189 PASS** (+5), build обоих sln 0/0, **125/125 PASS** (+6), core-тесты **1189/1189 PASS** (+5), build обоих sln 0/0,
`sh -n scripts/test.sh` OK, полный `sh scripts/test.sh` — «TEST ОК». Docker не поднимался, `sh -n scripts/test.sh` OK, полный `sh scripts/test.sh` — «TEST ОК». Docker не поднимался,
хвостов нет. Детали — task-c2-report.md. хвостов нет. Детали — task-c2-report.md.
- 2026-09-10: D (ИИ/ML без кредов) — выполнено. `POST /api/cards/reclassify` (batch inbox/ids) и новый - 2026-09-10: D (ИИ/ML без кредов) — выполнено. `POST /api/cards/reclassify` (batch inbox/ids) и новый
`POST /api/cards/{id}/reclassify` — реальная переклассификация через тот же конвейер, что и воркер `POST /api/cards/{id}/reclassify` — реальная переклассификация через тот же конвейер, что и воркер
(ИИ-фильтр → классификация `IAiClassifier` → сборка `CardComposer`/ContainerAccepts → обновление карточки (ИИ-фильтр → классификация `IAiClassifier` → сборка `CardComposer`/ContainerAccepts → обновление карточки
→ обучающие сигналы ML), без создания новой карточки. Фолбэк без/при недоступности ИИ — локальный → обучающие сигналы ML), без создания новой карточки. Фолбэк без/при недоступности ИИ — локальный
детерминированный разбор (`LocalFieldsParser`+`AiCardMapper`), без кредов не падает. Одно обновление детерминированный разбор (`LocalFieldsParser`+`AiCardMapper`), без кредов не падает. Одно обновление
карточки — новый порт `ICardStore.ApplyReclassificationAsync` (+ `CardReclassificationDto`); спам/фильтр карточки — новый порт `ICardStore.ApplyReclassificationAsync` (+ `CardReclassificationDto`); спам/фильтр
→ корзина через `CardsService.TrashCardAsync(teach:false)` + сигнал «спам» 0.4. Логика обучения ML вынесена → корзина через `CardsService.TrashCardAsync(teach:false)` + сигнал «спам» 0.4. Логика обучения ML вынесена
в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация, без дублей); вес `AiPushWeight` в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация, без дублей); вес `AiPushWeight`
централизован в `MlLearningLabels`. Одно переклассификация за раз — `ReclassifyGate` (single-flight, busy). централизован в `MlLearningLabels`. Одно переклассификация за раз — `ReclassifyGate` (single-flight, busy).
Ответ расширен совместимо: `started/busy/attempted` + `reclassified/moved/kept/trashed/skipped/usedAi/reason`. Ответ расширен совместимо: `started/busy/attempted` + `reclassified/moved/kept/trashed/skipped/usedAi/reason`.
Аудит `card_reclassified`. Контрактный документ §reclassify обновлён. Учёт токенов ML проверен: покрыт Аудит `card_reclassified`. Контрактный документ §reclassify обновлён. Учёт токенов ML проверен: покрыт
без изменений (GrpcAiClassifier/TokenUsageRecorder — ИИ-путь, включая reclassify; GrpcMlClient — без изменений (GrpcAiClassifier/TokenUsageRecorder — ИИ-путь, включая reclassify; GrpcMlClient —
kind=ml + `deal.ml.*`; Local-адаптеры по решению этапа 10 не пишут). Build Deal.sln 0/0; kind=ml + `deal.ml.*`; Local-адаптеры по решению этапа 10 не пишут). Build Deal.sln 0/0;
core-тесты **1203/1203 PASS** (+14). Docker не поднимался, хвостов нет. Детали — task-d-report.md. core-тесты **1203/1203 PASS** (+14). Docker не поднимался, хвостов нет. Детали — task-d-report.md.
- 2026-09-10: E (закрытие остатков после этапа 12) — выполнено. (1) 10 копий приватного `FreeTcpPort()` в - 2026-09-10: E (закрытие остатков после этапа 12) — выполнено. (1) 10 копий приватного `FreeTcpPort()` в
`tests/Deal.Tests.Unit` заменены единым `TestPort.Allocate()` (`TestPort.cs`): выданные в процессе порты не `tests/Deal.Tests.Unit` заменены единым `TestPort.Allocate()` (`TestPort.cs`): выданные в процессе порты не
выдаются повторно, при коллизии биндинг порта 0 повторяется — редкий `AddressInUseException` при выдаются повторно, при коллизии биндинг порта 0 повторяется — редкий `AddressInUseException` при
параллельном прогоне устранён, поведение тестов не изменилось. (2) Добавлено SSE-событие `cards_reclassified` параллельном прогоне устранён, поведение тестов не изменилось. (2) Добавлено SSE-событие `cards_reclassified`
`{reclassified, moved}`: публикуется из обоих reclassify-эндпоинтов после успешного прохода `{reclassified, moved}`: публикуется из обоих reclassify-эндпоинтов после успешного прохода
(`started && reclassified > 0`), фронт слушает его в `api.js` и мягко перечитывает доску + статус ML в (`started && reclassified > 0`), фронт слушает его в `api.js` и мягко перечитывает доску + статус ML в
`lifecycle.js` (мёртвая ветка `leads_reclassified` переименована). Контракт §SSE/reclassify обновлён. `lifecycle.js` (мёртвая ветка `leads_reclassified` переименована). Контракт §SSE/reclassify обновлён.
Build Deal.sln 0/0; core-тесты **1203/1203 PASS**; `npm run build` и `npm run lint:i18n` зелёные; Build Deal.sln 0/0; core-тесты **1203/1203 PASS**; `npm run build` и `npm run lint:i18n` зелёные;
`dotnet build-server shutdown` выполнен, хвостов нет. Детали — task-e-report.md. `dotnet build-server shutdown` выполнен, хвостов нет. Детали — task-e-report.md.
## Остатки ## Остатки
- Закрыты в задаче E: гонка `FreeTcpPort()` в тест-харнессе core и отсутствие SSE-события о завершении - Закрыты в задаче E: гонка `FreeTcpPort()` в тест-харнессе core и отсутствие SSE-события о завершении
переклассификации. Детали — task-e-report.md. переклассификации. Детали — task-e-report.md.
- Двойная перезагрузка доски у инициатора batch-reclassify (ответ эндпоинта + SSE): осознанно оставлено ради - Двойная перезагрузка доски у инициатора batch-reclassify (ответ эндпоинта + SSE): осознанно оставлено ради
сохранения текущего поведения и работы при отвале SSE. сохранения текущего поведения и работы при отвале SSE.
- Мёртвые ветки фронта `boards_changed`/`pipeline_stats`: core этих SSE-событий не публикует (наследие этапа 3). - Мёртвые ветки фронта `boards_changed`/`pipeline_stats`: core этих SSE-событий не публикует (наследие этапа 3).
- Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный). - Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный).
## Остатки после этапа 12 — закрыто координатором (2026-09-10) ## Остатки после этапа 12 — закрыто координатором (2026-09-10)
- **Доки под этап 12**: `docs/api/api-map.md` (real-reclassify + его форма ответа, `POST /api/operator/maintenance/tenants/migrate`, - **Доки под этап 12**: `docs/api/api-map.md` (real-reclassify + его форма ответа, `POST /api/operator/maintenance/tenants/migrate`,
удалены упоминания демо/`DEAL_DEMO`, абзац про rate-limit/retention); `docs/technical` (rate-limit на удалены упоминания демо/`DEAL_DEMO`, абзац про rate-limit/retention); `docs/technical` (rate-limit на
`rate_limit_counters`, retention, metrics/алерты, скрипты нагрузки, скан уязвимостей). `rate_limit_counters`, retention, metrics/алерты, скрипты нагрузки, скан уязвимостей).
- **Prometheus alert rules**: `deploy/observability/prometheus-rules.yml` (6 правил), подключены в - **Prometheus alert rules**: `deploy/observability/prometheus-rules.yml` (6 правил), подключены в
`prometheus.yml` и смонтированы в compose prod+dev; `promtool`/`compose config` — OK. `prometheus.yml` и смонтированы в compose prod+dev; `promtool`/`compose config` — OK.
- **Мусор**: удалён `deploy/observability/promtail.yml;C`; следов `*.orig/*.bak/*;*` нет. - **Мусор**: удалён `deploy/observability/promtail.yml;C`; следов `*.orig/*.bak/*;*` нет.
- **Нагрузка**: `scripts/loadtest/` (bash+curl и k6-вариант) + README. - **Нагрузка**: `scripts/loadtest/` (bash+curl и k6-вариант) + README.
- **Скан уязвимостей (read-only)**: core `dotnet list package --vulnerable --include-transitive` — 0 уязвимых - **Скан уязвимостей (read-only)**: core `dotnet list package --vulnerable --include-transitive` — 0 уязвимых
пакетов (12 проектов); frontend `npm audit` — 0. пакетов (12 проектов); frontend `npm audit` — 0.
## Три базовых документа (сопровождение) ## Три базовых документа (сопровождение)
Ведятся постоянно по договорённости: Ведятся постоянно по договорённости:
- **ТЗ** `docs/spec/ТЗ-дейл-новая-архитектура.md` (версия 1.0, этапы 0–12); - **ТЗ** `docs/spec/ТЗ-дейл-новая-архитектура.md` (версия 1.0, этапы 0–12);
- **Инструкция пользователя** `docs/user-guide/Инструкция-пользователя-Дейл.md` (версия 1.3, этапы 0–12); - **Инструкция пользователя** `docs/user-guide/Инструкция-пользователя-Дейл.md` (версия 1.3, этапы 0–12);
- **Техническая документация** `docs/technical/Техническая-документация-Дейл.md` (версия 2.0, этапы 0–12). - **Техническая документация** `docs/technical/Техническая-документация-Дейл.md` (версия 2.0, этапы 0–12).
Плюс поддерживаются: `docs/api/api-map.md`, `docs/superpowers/STATUS.md`, контракты этапов 9–10 и планы/ledgers. Плюс поддерживаются: `docs/api/api-map.md`, `docs/superpowers/STATUS.md`, контракты этапов 9–10 и планы/ledgers.
## Добивка по ТЗ — внешний вид (§8.12), 2026-09-10 ## Добивка по ТЗ — внешний вид (§8.12), 2026-09-10
Аудит ТЗ нашёл единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: не было раздела Аудит ТЗ нашёл единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: не было раздела
оформления, тема только тёмная. Закрыто во фронтенде (`src/frontend`, без новых зависимостей): оформления, тема только тёмная. Закрыто во фронтенде (`src/frontend`, без новых зависимостей):
- **Светлая тема через токены.** Тёмная палитра — дефолт в `@theme` (`style.css`, вид 1:1). Светлая — - **Светлая тема через токены.** Тёмная палитра — дефолт в `@theme` (`style.css`, вид 1:1). Светлая —
блок `:root[data-theme="light"]` с теми же токенами (+ тени, скроллбар, линии сетки входа, `.tgmd`, блок `:root[data-theme="light"]` с теми же токенами (+ тени, скроллбар, линии сетки входа, `.tgmd`,
`color-scheme`). Компоненты не переписывались. `color-scheme`). Компоненты не переписывались.
- **Полупрозрачные слои** `bg-white/N` / `border-white/N`: в светлой теме токен `--color-white` - **Полупрозрачные слои** `bg-white/N` / `border-white/N`: в светлой теме токен `--color-white`
указывает на тёмный оттенок (мягкие серые подложки/границы); литеральные `text-white`/`bg-white` указывает на тёмный оттенок (мягкие серые подложки/границы); литеральные `text-white`/`bg-white`
(текст на градиенте, QR, ползунок) восстановлены отдельными правилами; добавлены `--color-on-brand`, (текст на градиенте, QR, ползунок) восстановлены отдельными правилами; добавлены `--color-on-brand`,
`--color-on-warn` для текста поверх сплошной заливки. `accent-[#8b8ff8]``accent-brand` (4 места), `--color-on-warn` для текста поверх сплошной заливки. `accent-[#8b8ff8]``accent-brand` (4 места),
убраны инлайн `color-scheme: dark` (теперь наследуется от `html`). убраны инлайн `color-scheme: dark` (теперь наследуется от `html`).
- **Раздел «Внешний вид»** в настройках (вкладка `appearance` рядом с «Уведомлениями»), три варианта: - **Раздел «Внешний вид»** в настройках (вкладка `appearance` рядом с «Уведомлениями»), три варианта:
Тёмная (дефолт) / Светлая / Системная; мгновенное переключение и тост. Строки — в словаре (`settings.tema-*`). Тёмная (дефолт) / Светлая / Системная; мгновенное переключение и тост. Строки — в словаре (`settings.tema-*`).
- **Сохранение и отсутствие мигания.** `composables/theme.js` (`dark|light|system`, ключ - **Сохранение и отсутствие мигания.** `composables/theme.js` (`dark|light|system`, ключ
`localStorage.leadradar_theme`), инлайн-скрипт в `index.html` применяет тему до первого рендера; `localStorage.leadradar_theme`), инлайн-скрипт в `index.html` применяет тему до первого рендера;
режим `system` слушает `prefers-color-scheme`. режим `system` слушает `prefers-color-scheme`.
- **Проверка:** `npm run build` — зелёный, `npm run lint:i18n` — зелёный; тёмный вид не менялся. - **Проверка:** `npm run build` — зелёный, `npm run lint:i18n` — зелёный; тёмный вид не менялся.
- Техдок дополнен разделом §15. Детали — `task-tz-theme-report.md`. - Техдок дополнен разделом §15. Детали — `task-tz-theme-report.md`.
## Добивка по ТЗ — бэкенд core (ML/исключения/группы/health/suspicious), 2026-09-10 ## Добивка по ТЗ — бэкенд core (ML/исключения/группы/health/suspicious), 2026-09-10
Закрыты частично закрытые пункты аудита ТЗ (только `src/core`, тесты + отчёт): Закрыты частично закрытые пункты аудита ТЗ (только `src/core`, тесты + отчёт):
1. **§8 ML — проверка на канале/сообщении (`POST /api/ml/candidates|apply`).** Заглушки заменены 1. **§8 ML — проверка на канале/сообщении (`POST /api/ml/candidates|apply`).** Заглушки заменены
рабочим `MlReviewService`: кандидаты объединяются из очереди/отсева/карточек по (dialogId, msgId), рабочим `MlReviewService`: кандидаты объединяются из очереди/отсева/карточек по (dialogId, msgId),
с текущим вердиктом и мнением ML; `apply` применяет ручное решение (`skip`/`spam`/`board:<id>`) с текущим вердиктом и мнением ML; `apply` применяет ручное решение (`skip`/`spam`/`board:<id>`)
через существующие сервисы (`CardsService`/`PipelineProcessingService`/`IMlClient.PushAsync`). через существующие сервисы (`CardsService`/`PipelineProcessingService`/`IMlClient.PushAsync`).
2. **§5.14/§8 глобальные исключения (стоп до ML/ИИ).** Настройки `excludeKeywords/excludeLocations/ 2. **§5.14/§8 глобальные исключения (стоп до ML/ИИ).** Настройки `excludeKeywords/excludeLocations/
excludeTypes/excludeBudgetFrom/excludeBudgetTo` (каталог/дефолты/PATCH/GET), чистый excludeTypes/excludeBudgetFrom/excludeBudgetTo` (каталог/дефолты/PATCH/GET), чистый
`GlobalExclusionRules`, вызов на стоп-этапе воркера; причина отсева называет исключение. `GlobalExclusionRules`, вызов на стоп-этапе воркера; причина отсева называет исключение.
3. **§6.3 группы фильтров колонки.** `ContainerRulesDto`/`IContainerRules` расширены группами 3. **§6.3 группы фильтров колонки.** `ContainerRulesDto`/`IContainerRules` расширены группами
`levels/locations/types/prices` (обратная совместимость JSON сохранена); матчинг, `matchHits`, `levels/locations/types/prices` (обратная совместимость JSON сохранена); матчинг, `matchHits`,
`describe` и `RulesJson`-сериализация обновлены. `describe` и `RulesJson`-сериализация обновлены.
4. **§10.2 глубины очередей в health.** Общий `RuntimeDepthsCollector` (переиспользован и метриками): 4. **§10.2 глубины очередей в health.** Общий `RuntimeDepthsCollector` (переиспользован и метриками):
`queues:{pipeline,mlOutbox}` и `sessions:{active}` в `GET /api/operator/health`. `queues:{pipeline,mlOutbox}` и `sessions:{active}` в `GET /api/operator/health`.
5. **§10.5 подозрительная активность.** `SuspiciousActivityService` (пороговые правила по аудиту) + 5. **§10.5 подозрительная активность.** `SuspiciousActivityService` (пороговые правила по аудиту) +
`GET /api/operator/analytics/suspicious`. `GET /api/operator/analytics/suspicious`.
- **Проверка:** `dotnet build Deal.sln` — 0/0; core-тесты 1245/1245 (было 1203). - **Проверка:** `dotnet build Deal.sln` — 0/0; core-тесты 1245/1245 (было 1203).
- Детали — `task-tz-backend-report.md`; контракт ML/health — в `docs/architecture/2026-09-10-unified-api-contract.md`. - Детали — `task-tz-backend-report.md`; контракт ML/health — в `docs/architecture/2026-09-10-unified-api-contract.md`.
## Добивка по ТЗ — фронтенд (UI-части), 2026-09-10 ## Добивка по ТЗ — фронтенд (UI-части), 2026-09-10
Закрыты UI-части пунктов, чьи бэкенд-контракты закрыты выше (только `src/frontend`, без новых зависимостей): Закрыты UI-части пунктов, чьи бэкенд-контракты закрыты выше (только `src/frontend`, без новых зависимостей):
1. **«Открыть исходник» на карточке (§6.6).** `Card.vue`: рядом с комментарием/корзиной/переносом — 1. **«Открыть исходник» на карточке (§6.6).** `Card.vue`: рядом с комментарием/корзиной/переносом —
быстрая кнопка-ссылка на исходное сообщение (тот же `tgSourceUrl`: `channel.handle`/`sourceDialogId`+ быстрая кнопка-ссылка на исходное сообщение (тот же `tgSourceUrl`: `channel.handle`/`sourceDialogId`+
`sourceMsgId`); нет данных — кнопки нет. Только режим дашборда. `sourceMsgId`); нет данных — кнопки нет. Только режим дашборда.
2. **Глобальные исключения в настройках (§5.14/§8).** Вкладка «Фильтры входящих» (`StopTab.vue`): блок 2. **Глобальные исключения в настройках (§5.14/§8).** Вкладка «Фильтры входящих» (`StopTab.vue`): блок
«Глобальные исключения» — теги ключевых слов и локаций, чипы типов (вакансия/фриланс/объявление), «Глобальные исключения» — теги ключевых слов и локаций, чипы типов (вакансия/фриланс/объявление),
диапазон бюджета. Персист — PATCH `/api/settings` (`excludeKeywords/excludeLocations/excludeTypes/ диапазон бюджета. Персист — PATCH `/api/settings` (`excludeKeywords/excludeLocations/excludeTypes/
excludeBudgetFrom/excludeBudgetTo`), поля в `core.js`/`settings.js`. excludeBudgetFrom/excludeBudgetTo`), поля в `core.js`/`settings.js`.
3. **Группы фильтров колонки (§6.3).** `BoardRulesDialog.vue`: добавлены `levels`, `locations` (группы-теги), 3. **Группы фильтров колонки (§6.3).** `BoardRulesDialog.vue`: добавлены `levels`, `locations` (группы-теги),
`types` (чипы vacancy/freelance/announcement) и `prices` (второй диапазон, общая разметка с `budget`). `types` (чипы vacancy/freelance/announcement) и `prices` (второй диапазон, общая разметка с `budget`).
Сохранение — прежним `saveBoardForm`; старые `rules` без новых групп разбираются как раньше. Сохранение — прежним `saveBoardForm`; старые `rules` без новых групп разбираются как раньше.
Заодно исправлен сломанный счётчик значений группы (выводил литерал шаблона вместо слова). Заодно исправлен сломанный счётчик значений группы (выводил литерал шаблона вместо слова).
4. **Оператор-консоль (§10.2/§10.5).** «Состояние» (`HealthSection.vue`): карточка «Очереди и сессии» 4. **Оператор-консоль (§10.2/§10.5).** «Состояние» (`HealthSection.vue`): карточка «Очереди и сессии»
(`queues.pipeline`, `queues.mlOutbox`, `sessions.active`). «Аналитика» (`AnalyticsSection.vue`): (`queues.pipeline`, `queues.mlOutbox`, `sessions.active`). «Аналитика» (`AnalyticsSection.vue`):
подраздел «Подозрительная активность» (`GET /api/operator/analytics/suspicious`) со списком находок. подраздел «Подозрительная активность» (`GET /api/operator/analytics/suspicious`) со списком находок.
- **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; светлая тема не ломалась - **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; светлая тема не ломалась
(новые элементы — на существующих токенах). Внешние процессы не запускались (`:5173` свободен). (новые элементы — на существующих токенах). Внешние процессы не запускались (`:5173` свободен).
- Детали и найденные расхождения — `task-tz-frontend-report.md`. - Детали и найденные расхождения — `task-tz-frontend-report.md`.
## Telegram-ключи: вариант A (глобальные ключи оператора, ТЗ §4.1/§8.1), 2026-09-10 ## Telegram-ключи: вариант A (глобальные ключи оператора, ТЗ §4.1/§8.1), 2026-09-10
Решение владельца (вариант A): `api_id`/`api_hash` задаёт **оператор глобально**, тенант — только Решение владельца (вариант A): `api_id`/`api_hash` задаёт **оператор глобально**, тенант — только
подключает аккаунт. Только `src/core` + комментарии proto (без фронта/telegram-service/deploy). подключает аккаунт. Только `src/core` + комментарии proto (без фронта/telegram-service/deploy).
1. **Глобальное хранилище (public).** Новая системная таблица `public.global_settings` 1. **Глобальное хранилище (public).** Новая системная таблица `public.global_settings`
(`Key` PK, `Value` text, `UpdatedAt`): сущность `GlobalSettingEntity`, конфигурация (`Key` PK, `Value` text, `UpdatedAt`): сущность `GlobalSettingEntity`, конфигурация
`GlobalSettingConfiguration`, `DbSet`/`ApplyConfiguration` в `DealDbContext`, EF-адаптер `GlobalSettingConfiguration`, `DbSet`/`ApplyConfiguration` в `DealDbContext`, EF-адаптер
`GlobalSettingsStore`, порт `IGlobalSettingsStore` + каталог `GlobalSettingsKeys` (`telegramKeys`) `GlobalSettingsStore`, порт `IGlobalSettingsStore` + каталог `GlobalSettingsKeys` (`telegramKeys`)
в модуле Settings, регистрация в `AddDealPersistence`. Системная миграция в модуле Settings, регистрация в `AddDealPersistence`. Системная миграция
`Migrations/20260910194443_GlobalSettings.cs` (`--context DealDbContext`). `Migrations/20260910194443_GlobalSettings.cs` (`--context DealDbContext`).
2. **Операторские ручки.** `GET/PUT /api/operator/settings/telegram-keys` (`OperatorSettingsEndpoints`): 2. **Операторские ручки.** `GET/PUT /api/operator/settings/telegram-keys` (`OperatorSettingsEndpoints`):
GET — маска (`apiId` открыт, `apiHash` маска, `keysSet`), PUT `{apiId, apiHash}` — валидация GET — маска (`apiId` открыт, `apiHash` маска, `keysSet`), PUT `{apiId, apiHash}` — валидация
(api_id 5..9 цифр, api_hash непустой), шифрование `apiHash` (`enc:`), аудит `telegram_keys_changed` (api_id 5..9 цифр, api_hash непустой), шифрование `apiHash` (`enc:`), аудит `telegram_keys_changed`
(без секретов). 401 без операторской сессии, ошибки `{detail}`. (без секретов). 401 без операторской сессии, ошибки `{detail}`.
3. **Ядро читает глобальные ключи.** `TelegramKeysService` переведён на `IGlobalSettingsStore` 3. **Ядро читает глобальные ключи.** `TelegramKeysService` переведён на `IGlobalSettingsStore`
(ключ `telegramKeys`); `TgStatusService`/`start-phone`/`start-qr` работают от глобальных ключей. (ключ `telegramKeys`); `TgStatusService`/`start-phone`/`start-qr` работают от глобальных ключей.
Нет ключей — `400 {detail} «Ключи Telegram не заданы оператором»`, статус отдаёт `keysSet=false`. Нет ключей — `400 {detail} «Ключи Telegram не заданы оператором»`, статус отдаёт `keysSet=false`.
4. **Настройки тенанта.** `tgKeys` удалён из `PublicForms`/`TgKeysPublicDto`/`SettingKind`/ 4. **Настройки тенанта.** `tgKeys` удалён из `PublicForms`/`TgKeysPublicDto`/`SettingKind`/
`SettingsDefaults`/`SettingsKeys`/`PatchSecrets` и `PublicSettingsDto` (миграция данных не нужна — `SettingsDefaults`/`SettingsKeys`/`PatchSecrets` и `PublicSettingsDto` (миграция данных не нужна —
данные тестовые). Вкладка/статус Telegram у тенанта остаются (подключение аккаунта). данные тестовые). Вкладка/статус Telegram у тенанта остаются (подключение аккаунта).
5. **Proto-комментарии** `src/contracts/telegram.proto`: tgKeys тенанта → глобальные ключи оператора. 5. **Proto-комментарии** `src/contracts/telegram.proto`: tgKeys тенанта → глобальные ключи оператора.
6. **Контракт для фронта** — в `docs/architecture/2026-09-10-operator-analytics-contract.md` 6. **Контракт для фронта** — в `docs/architecture/2026-09-10-operator-analytics-contract.md`
(раздел «Операторские настройки: глобальные ключи Telegram», + событие `telegram_keys_changed`). (раздел «Операторские настройки: глобальные ключи Telegram», + событие `telegram_keys_changed`).
- **Проверка:** `dotnet build Deal.sln` — 0/0; core-тесты **1271/1271** (было 1245). - **Проверка:** `dotnet build Deal.sln` — 0/0; core-тесты **1271/1271** (было 1245).
Миграция сгенерирована; применение к dev-Postgres отложено (Postgres :5433 не поднят в этой сессии). Миграция сгенерирована; применение к dev-Postgres отложено (Postgres :5433 не поднят в этой сессии).
- Детали — `task-tgkeys-report.md`. - Детали — `task-tgkeys-report.md`.
## Telegram-ключи: фронтенд (вариант A), 2026-09-10 ## Telegram-ключи: фронтенд (вариант A), 2026-09-10
Закрыты UI-части варианта A (только `src/frontend`, без новых зависимостей): Закрыты UI-части варианта A (только `src/frontend`, без новых зависимостей):
1. **Оператор-консоль — раздел «Telegram».** Новый `views/operator/TelegramSection.vue` (пункт меню 1. **Оператор-консоль — раздел «Telegram».** Новый `views/operator/TelegramSection.vue` (пункт меню
после «Состояния»): маскированный снимок глобальных ключей (`keysSet`, `apiId`, маска `apiHash`), после «Состояния»): маскированный снимок глобальных ключей (`keysSet`, `apiId`, маска `apiHash`),
форма `api_id`/`api_hash` с клиентской валидацией (5–9 цифр / непустой секрет), `PUT` форма `api_id`/`api_hash` с клиентской валидацией (5–9 цифр / непустой секрет), `PUT`
`/api/operator/settings/telegram-keys`; после сохранения секрет не удерживается. В `store/operator.js` — `/api/operator/settings/telegram-keys`; после сохранения секрет не удерживается. В `store/operator.js` —
`op.tgKeys` + `loadTelegramKeys`/`saveTelegramKeys`; в `api.js` добавлен `api.put`; в каталог `op.tgKeys` + `loadTelegramKeys`/`saveTelegramKeys`; в `api.js` добавлен `api.put`; в каталог
`AUDIT_EVENT_TYPES` — `telegram_keys_changed`. `AUDIT_EVENT_TYPES` — `telegram_keys_changed`.
2. **Настройки тенанта — ключи убраны.** Из `settings/TelegramTab.vue` удалён блок api_id/api_hash 2. **Настройки тенанта — ключи убраны.** Из `settings/TelegramTab.vue` удалён блок api_id/api_hash
(осталось подключение аккаунта/статус/QR); при `keysSet:false` — предупреждение, что подключение (осталось подключение аккаунта/статус/QR); при `keysSet:false` — предупреждение, что подключение
недоступно, пока оператор не задаст ключи. Из `store/settings.js` (обработка `tgKeys` и недоступно, пока оператор не задаст ключи. Из `store/settings.js` (обработка `tgKeys` и
`saveSettings`) и `store/core.js` (`state.apiId`/`apiHash`) удалены мёртвые поля; `tgKeysSet` `saveSettings`) и `store/core.js` (`state.apiId`/`apiHash`) удалены мёртвые поля; `tgKeysSet`
оставлен (приходит из `/api/tg/status`). Строки — в словаре, мёртвые ключи вычищены. оставлен (приходит из `/api/tg/status`). Строки — в словаре, мёртвые ключи вычищены.
- **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; тёмная/светлая темы не - **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; тёмная/светлая темы не
ломались; `:5173` свободен (процессы не запускались). ломались; `:5173` свободен (процессы не запускались).
- Детали и найденные расхождения — `task-tgkeys-frontend-report.md`. - Детали и найденные расхождения — `task-tgkeys-frontend-report.md`.
## Telegram-ключи: частичное обновление + применение миграции + сквозная проверка, 2026-09-10 ## Telegram-ключи: частичное обновление + применение миграции + сквозная проверка, 2026-09-10
1. **Частичное обновление PUT.** `PUT /api/operator/settings/telegram-keys` принимает `{apiId?, apiHash?}`: 1. **Частичное обновление PUT.** `PUT /api/operator/settings/telegram-keys` принимает `{apiId?, apiHash?}`:
непереданное поле (`null`) сохраняет текущее значение, явное (`""`/маска/`enc:`) валидируется. непереданное поле (`null`) сохраняет текущее значение, явное (`""`/маска/`enc:`) валидируется.
Если ключей ещё нет — оба обязательны (`400 {detail} «Ключи ещё не заданы — укажите и api_id, и api_hash»`); Если ключей ещё нет — оба обязательны (`400 {detail} «Ключи ещё не заданы — укажите и api_id, и api_hash»`);
ни одного поля — `400 «Укажите api_id и api_hash»`. Правило слияния — в `OperatorSettingsEndpoints` ни одного поля — `400 «Укажите api_id и api_hash»`. Правило слияния — в `OperatorSettingsEndpoints`
(`GetAsync` текущих + `SaveAsync`); `TelegramKeysRequest`/`TelegramKeysService.SaveAsync` — без смены (`GetAsync` текущих + `SaveAsync`); `TelegramKeysRequest`/`TelegramKeysService.SaveAsync` — без смены
строгой валидации. Контракт обновлён (`docs/architecture/2026-09-10-operator-analytics-contract.md`), строгой валидации. Контракт обновлён (`docs/architecture/2026-09-10-operator-analytics-contract.md`),
тесты +4 (только apiId, только apiHash, частичное без ключей, пустое тело). тесты +4 (только apiId, только apiHash, частичное без ключей, пустое тело).
2. **Миграция `GlobalSettings` применена** к dev-Postgres (:5433, контейнер `deal-postgres`): 2. **Миграция `GlobalSettings` применена** к dev-Postgres (:5433, контейнер `deal-postgres`):
`dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api` `dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api`
→ `Applying migration '20260910194443_GlobalSettings'. Done.` Таблица `public.global_settings` → `Applying migration '20260910194443_GlobalSettings'. Done.` Таблица `public.global_settings`
(`Key` PK, `Value` text, `UpdatedAt` timestamptz) подтверждена `\d global_settings`. Postgres погашен. (`Key` PK, `Value` text, `UpdatedAt` timestamptz) подтверждена `\d global_settings`. Postgres погашен.
3. **Сквозная проверка.** Build 0 warnings/0 errors и тесты PASS по всем 4 решениям: core **1275/1275** 3. **Сквозная проверка.** Build 0 warnings/0 errors и тесты PASS по всем 4 решениям: core **1275/1275**
(было 1271), telegram **125/125**, ai **52/52**, ml **38/38**. Хвостов нет (`docker ps` без `deal-*`, (было 1271), telegram **125/125**, ai **52/52**, ml **38/38**. Хвостов нет (`docker ps` без `deal-*`,
порты 5433/5080/5082/51015103 свободны, `dotnet build-server shutdown`). порты 5433/5080/5082/51015103 свободны, `dotnet build-server shutdown`).
- Детали — `task-final-report.md`. - Детали — `task-final-report.md`.
@@ -1,110 +1,110 @@
# Task A — Метрики (OpenTelemetry → Prometheus + Grafana). Отчёт # Task A — Метрики (OpenTelemetry → Prometheus + Grafana). Отчёт
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет A). План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет A).
Статус: **выполнено**. Все 4 процесса отдают `/metrics` в формате Prometheus, Prometheus скрейпит Статус: **выполнено**. Все 4 процесса отдают `/metrics` в формате Prometheus, Prometheus скрейпит
их в профиле `observability`, Grafana провижинит datasource и дашборд метрик. их в профиле `observability`, Grafana провижинит datasource и дашборд метрик.
## Что сделано ## Что сделано
### 1. Метрики во всех 4 процессах ### 1. Метрики во всех 4 процессах
- Пакеты OTel (1.17.x) добавлены в `src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj` - Пакеты OTel (1.17.x) добавлены в `src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj`
(для telegram/ai/ml) и в `src/core/Deal.Api/Deal.Api.csproj` (ядро): (для telegram/ai/ml) и в `src/core/Deal.Api/Deal.Api.csproj` (ядро):
`OpenTelemetry.Extensions.Hosting` 1.17.0, `OpenTelemetry.Exporter.Prometheus.AspNetCore` `OpenTelemetry.Extensions.Hosting` 1.17.0, `OpenTelemetry.Exporter.Prometheus.AspNetCore`
1.17.0-beta.1, `OpenTelemetry.Instrumentation.AspNetCore` 1.17.0, 1.17.0-beta.1, `OpenTelemetry.Instrumentation.AspNetCore` 1.17.0,
`OpenTelemetry.Instrumentation.Http` 1.17.0, `OpenTelemetry.Instrumentation.GrpcNetClient` `OpenTelemetry.Instrumentation.Http` 1.17.0, `OpenTelemetry.Instrumentation.GrpcNetClient`
1.17.0-beta.1. 1.17.0-beta.1.
- ⚠ Важно: Prometheus-экспортёр и GrpcNetClient выпускаются только pre-release-линией (стабильных - ⚠ Важно: Prometheus-экспортёр и GrpcNetClient выпускаются только pre-release-линией (стабильных
1.17.0 нет) — версии зафиксированы на `-beta.1`; `GrpcNetClient` даёт трейс-инструментацию 1.17.0 нет) — версии зафиксированы на `-beta.1`; `GrpcNetClient` даёт трейс-инструментацию
исходящих gRPC (метрик в пакете нет), оставлен как задел под будущий трейсинг. исходящих gRPC (метрик в пакете нет), оставлен как задел под будущий трейсинг.
- Общий хелпер сервисов — `src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs`: - Общий хелпер сервисов — `src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs`:
`AddDealMetrics(builder, metricsPort)` (Kestrel-эндпоинт метрик + OTel + экспортёр) и `AddDealMetrics(builder, metricsPort)` (Kestrel-эндпоинт метрик + OTel + экспортёр) и
`MapDealMetrics(app)` (`/metrics`). Вызов — 2 строки из `Program.cs` каждого сервиса через `MapDealMetrics(app)` (`/metrics`). Вызов — 2 строки из `Program.cs` каждого сервиса через
`configureBuilder`-хук (тесты хостов не затрагиваются — метрики в них не поднимаются). `configureBuilder`-хук (тесты хостов не затрагиваются — метрики в них не поднимаются).
- Ядро: зеркальный хелпер `src/core/Deal.Api/Observability/DealMetricsHosting.cs` (ядро не ссылается - Ядро: зеркальный хелпер `src/core/Deal.Api/Observability/DealMetricsHosting.cs` (ядро не ссылается
на обвязку gRPC-сервисов — по конвенции проекта держит свою копию). Вызов — из `Program.cs`. на обвязку gRPC-сервисов — по конвенции проекта держит свою копию). Вызов — из `Program.cs`.
- **Эндпоинт `/metrics` — отдельный HTTP/1.1 Kestrel-эндпоинт :9464** у всех 4 процессов - **Эндпоинт `/metrics` — отдельный HTTP/1.1 Kestrel-эндпоинт :9464** у всех 4 процессов
(env `METRICS_PORT`). Выбор обоснован: gRPC-порты :5101:5103/:5082 слушают только HTTP/2, а scrape (env `METRICS_PORT`). Выбор обоснован: gRPC-порты :5101:5103/:5082 слушают только HTTP/2, а scrape
Prometheus — обычный GET по HTTP/1.1; вынос на отдельный порт не меняет протокол gRPC-эндпоинтов. Prometheus — обычный GET по HTTP/1.1; вынос на отдельный порт не меняет протокол gRPC-эндпоинтов.
Порт наружу не публикуется (scrape — внутри compose-сети). Порт наружу не публикуется (scrape — внутри compose-сети).
### 2. Прикладные метрики (meter `Deal`, имена `deal.*`, метки низкокардинальные) ### 2. Прикладные метрики (meter `Deal`, имена `deal.*`, метки низкокардинальные)
- Инструменты — `src/core/Deal.SharedKernel/Observability/DealMetrics.cs` (static Meter — общий для - Инструменты — `src/core/Deal.SharedKernel/Observability/DealMetrics.cs` (static Meter — общий для
точек инкремента в разных модулях). Публичных меток с tenantId/userId/cardId **нет**. точек инкремента в разных модулях). Публичных меток с tenantId/userId/cardId **нет**.
- Инкремент там же, где уже пишутся данные (без дублирования логики): - Инкремент там же, где уже пишутся данные (без дублирования логики):
- `TokenUsageRecorder.AddAsync``deal.ai.calls` + `deal.ai.tokens{type=prompt|completion}` - `TokenUsageRecorder.AddAsync``deal.ai.calls` + `deal.ai.tokens{type=prompt|completion}`
(та же точка, что `token_usage_events` kind=ai); (та же точка, что `token_usage_events` kind=ai);
- `TokenUsageRecorder.AddEstimatedAsync``deal.ml.calls` + `deal.ml.tokens` (kind=ml); - `TokenUsageRecorder.AddEstimatedAsync``deal.ml.calls` + `deal.ml.tokens` (kind=ml);
- `AuditService.AppendAsync``deal.audit.events{event,actor}` (по типам/акторам). - `AuditService.AppendAsync``deal.audit.events{event,actor}` (по типам/акторам).
- Gauge-метрики собирает фоновый `src/core/Deal.Api/Observability/DealMetricsCollector.cs` - Gauge-метрики собирает фоновый `src/core/Deal.Api/Observability/DealMetricsCollector.cs`
(IHostedService, период 15 с; эталон StorageTickScheduler): (IHostedService, период 15 с; эталон StorageTickScheduler):
- `deal.pipeline.queue.depth` — сумма по тенантам из существующего - `deal.pipeline.queue.depth` — сумма по тенантам из существующего
`PipelineProcessingService.QueueCountsAsync` (new+filtered); `PipelineProcessingService.QueueCountsAsync` (new+filtered);
- `deal.ml.outbox.depth` — сумма из `IMlLearningStore.CountOutboxAsync` (count(MlOutbox)); - `deal.ml.outbox.depth` — сумма из `IMlLearningStore.CountOutboxAsync` (count(MlOutbox));
- `deal.sessions.active` — count(public.sessions) + count(public.operator_sessions) с - `deal.sessions.active` — count(public.sessions) + count(public.operator_sessions) с
непросроченным ExpiresAt. непросроченным ExpiresAt.
- Значения публикуются в `DealMetrics`; callback ObservableGauge отдаёт их Prometheus при scrape. - Значения публикуются в `DealMetrics`; callback ObservableGauge отдаёт их Prometheus при scrape.
### 3. Scrape / Prometheus / Grafana ### 3. Scrape / Prometheus / Grafana
- `deploy/observability/prometheus.yml` — job `deal` с таргетами - `deploy/observability/prometheus.yml` — job `deal` с таргетами
`core:9464 / telegram-service:9464 / ai-service:9464 / ml-service:9464` (target-метка `service`) + `core:9464 / telegram-service:9464 / ai-service:9464 / ml-service:9464` (target-метка `service`) +
само-мониторинг. Ключевое: `global.metric_name_validation_scheme: legacy` — Prometheus 3 иначе само-мониторинг. Ключевое: `global.metric_name_validation_scheme: legacy` — Prometheus 3 иначе
запрашивает UTF-8-схему и сохраняет метрики с точками (`deal.sessions.active`), ломая привычные запрашивает UTF-8-схему и сохраняет метрики с точками (`deal.sessions.active`), ломая привычные
имена; legacy-схема даёт стабильные `deal_sessions_active`, `http_server_request_duration_seconds_*`. имена; legacy-схема даёт стабильные `deal_sessions_active`, `http_server_request_duration_seconds_*`.
- `deploy/compose.prod.yml` — сервис `prometheus` (`prom/prometheus:v3.5.0`) в профиле - `deploy/compose.prod.yml` — сервис `prometheus` (`prom/prometheus:v3.5.0`) в профиле
`observability`: конфиг + volume `deal_prometheus_data`, retention 15 суток, UI только `observability`: конфиг + volume `deal_prometheus_data`, retention 15 суток, UI только
`127.0.0.1:9090`; Grafana `depends_on` prometheus; шапка/volume-секция обновлены. `127.0.0.1:9090`; Grafana `depends_on` prometheus; шапка/volume-секция обновлены.
- `deploy/compose.dev.yml` — тот же сервис `prometheus` (профиль `observability`, UI `:9090`) для - `deploy/compose.dev.yml` — тот же сервис `prometheus` (профиль `observability`, UI `:9090`) для
локальной проверки. локальной проверки.
- Grafana-провижининг: - Grafana-провижининг:
- `deploy/observability/grafana/provisioning/datasources/datasources.yml` — добавлен datasource - `deploy/observability/grafana/provisioning/datasources/datasources.yml` — добавлен datasource
Prometheus (uid `prometheus`, `http://prometheus:9090`; Loki остаётся default); Prometheus (uid `prometheus`, `http://prometheus:9090`; Loki остаётся default);
- `deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json` (uid `deal-metrics`) — - `deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json` (uid `deal-metrics`) —
RPS/p95/5xx по сервисам, токены и вызовы AI/ML, глубины очередей, активные сессии, топ событий RPS/p95/5xx по сервисам, токены и вызовы AI/ML, глубины очередей, активные сессии, топ событий
аудита. аудита.
### 4. Документация ### 4. Документация
- `docs/technical/Техническая-документация-Дейл.md` — §7 дополнен подразделом «Метрики - `docs/technical/Техническая-документация-Дейл.md` — §7 дополнен подразделом «Метрики
(Prometheus + Grafana)»: как поднять профиль, порты (Grafana 3001, Prometheus 9090, /metrics 9464), (Prometheus + Grafana)»: как поднять профиль, порты (Grafana 3001, Prometheus 9090, /metrics 9464),
перечень метрик, где смотреть; обновлены таблица стека, §8 (профиль observability), сводки. перечень метрик, где смотреть; обновлены таблица стека, §8 (профиль observability), сводки.
## Порты / эндпоинты ## Порты / эндпоинты
| Процесс | gRPC/HTTP | Метрики | | Процесс | gRPC/HTTP | Метрики |
|---|---|---| |---|---|---|
| core | 5080 HTTP, 5082 gRPC | `:9464/metrics` (HTTP/1.1) | | core | 5080 HTTP, 5082 gRPC | `:9464/metrics` (HTTP/1.1) |
| telegram-service | 5101 gRPC | `:9464/metrics` | | telegram-service | 5101 gRPC | `:9464/metrics` |
| ai-service | 5102 gRPC | `:9464/metrics` | | ai-service | 5102 gRPC | `:9464/metrics` |
| ml-service | 5103 gRPC | `:9464/metrics` | | ml-service | 5103 gRPC | `:9464/metrics` |
| prometheus | — | UI `127.0.0.1:9090` (prod), `:9090` (dev) | | prometheus | — | UI `127.0.0.1:9090` (prod), `:9090` (dev) |
| grafana | — | UI `127.0.0.1:3001` | | grafana | — | UI `127.0.0.1:3001` |
Порт метрик переопределяется env `METRICS_PORT` (при запуске нескольких процессов на хосте без compose Порт метрик переопределяется env `METRICS_PORT` (при запуске нескольких процессов на хосте без compose
нужны разные значения). нужны разные значения).
## Как проверял ## Как проверял
- **Build 0/0**: `src/core/Deal.sln`, `src/ai-service/Deal.Ai.sln`, `src/ml-service/Deal.Ml.sln`, - **Build 0/0**: `src/core/Deal.sln`, `src/ai-service/Deal.Ai.sln`, `src/ml-service/Deal.Ml.sln`,
`src/telegram-service/Deal.Telegram.sln` — все rc=0. `src/telegram-service/Deal.Telegram.sln` — все rc=0.
- **Тесты**: ai/ml/telegram — PASS полностью. core — PASS, кроме 3 предсуществующих падений - **Тесты**: ai/ml/telegram — PASS полностью. core — PASS, кроме 3 предсуществующих падений
`PromptDefaultsTests` (тесты читают `export const DEFAULT_AI_PROMPT` из `src/frontend/src/data.js`, а `PromptDefaultsTests` (тесты читают `export const DEFAULT_AI_PROMPT` из `src/frontend/src/data.js`, а
фронт переписан пакетом C: промпты вынесены в `src/frontend/src/i18n/locales/ru.data.js`). К пакету A фронт переписан пакетом C: промпты вынесены в `src/frontend/src/i18n/locales/ru.data.js`). К пакету A
и моим изменениям отношения не имеет; фронт не трогал. и моим изменениям отношения не имеет; фронт не трогал.
- **Конфиги**: `docker compose ... --profile observability config --quiet` — prod rc=0 (с обязательными - **Конфиги**: `docker compose ... --profile observability config --quiet` — prod rc=0 (с обязательными
env), dev rc=0. YAML (`prometheus.yml`, datasources, dashboards) и JSON дашборда проверены парсером; env), dev rc=0. YAML (`prometheus.yml`, datasources, dashboards) и JSON дашборда проверены парсером;
`promtool check config` — SUCCESS. `promtool check config` — SUCCESS.
- **Живой прогон (dev-стек, Docker)**: `docker compose -f deploy/compose.dev.yml up -d --build` - **Живой прогон (dev-стек, Docker)**: `docker compose -f deploy/compose.dev.yml up -d --build`
`/metrics` всех 4 процессов отдают валидный Prometheus-формат (у core видны gauge `/metrics` всех 4 процессов отдают валидный Prometheus-формат (у core видны gauge
`deal_sessions_active`, `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` и `deal_sessions_active`, `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` и
`http_server_request_duration_seconds_count`) → поднят `prometheus` (профиль observability) → `http_server_request_duration_seconds_count`) → поднят `prometheus` (профиль observability) →
`/api/v1/targets` — 5/5 `up`, контрольные PromQL дашборда (sessions, RPS by service, p95) возвращают `/api/v1/targets` — 5/5 `up`, контрольные PromQL дашборда (sessions, RPS by service, p95) возвращают
данные. данные.
- **Очистка**: `docker compose -f deploy/compose.dev.yml --profile observability down` (8/8 removed), - **Очистка**: `docker compose -f deploy/compose.dev.yml --profile observability down` (8/8 removed),
`docker ps` без deal-контейнеров, `dotnet build-server shutdown`. Хвостов нет. `docker ps` без deal-контейнеров, `dotnet build-server shutdown`. Хвостов нет.
## Осталось / замечания ## Осталось / замечания
- Токен/вызов-счётчики AI/ML появляются в `/metrics` только после первого инкремента (стандартное - Токен/вызов-счётчики AI/ML появляются в `/metrics` только после первого инкремента (стандартное
поведение экспортёра) — живого платного ИИ-вызова без кредов нет, поэтому в приёмке их не было; код поведение экспортёра) — живого платного ИИ-вызова без кредов нет, поэтому в приёмке их не было; код
инкремента — в тех же точках, что `token_usage_events`. инкремента — в тех же точках, что `token_usage_events`.
- Отдельный Prometheus-конфиг для dev не заводил — общий `deploy/observability/prometheus.yml` - Отдельный Prometheus-конфиг для dev не заводил — общий `deploy/observability/prometheus.yml`
(имена сервисов совпадают). (имена сервисов совпадают).
- Живая проверка Grafana (загрузка дашборда UI) не гонялась — JSON/провижининг валидны; помечаю как - Живая проверка Grafana (загрузка дашборда UI) не гонялась — JSON/провижининг валидны; помечаю как
⚠ Manual. ⚠ Manual.
- Предсуществующие падения `PromptDefaultsTests` — за пакетом C/фронтом, не чинил (вне зоны задачи). - Предсуществующие падения `PromptDefaultsTests` — за пакетом C/фронтом, не чинил (вне зоны задачи).
@@ -1,116 +1,116 @@
# Task B — Устойчивость/безопасность (распределённый rate-limit, мгновенный разлогин, авто-очистки). Отчёт # Task B — Устойчивость/безопасность (распределённый rate-limit, мгновенный разлогин, авто-очистки). Отчёт
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет B). План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет B).
Статус: **выполнено**. Итог: `dotnet build Deal.sln` — 0/0; core-тесты **1184/1184 PASS** (+11 новых). Статус: **выполнено**. Итог: `dotnet build Deal.sln` — 0/0; core-тесты **1184/1184 PASS** (+11 новых).
Все живое-прогоны Postgres погашены (контейнеров `deal-*` не осталось; build-server остановлен). Все живое-прогоны Postgres погашены (контейнеров `deal-*` не осталось; build-server остановлен).
## B1. Распределённый rate-limit (хранилище на Postgres) ## B1. Распределённый rate-limit (хранилище на Postgres)
In-memory `FixedWindowRateLimiter` заменён на store-backed лимитер; состояние счётчиков — в новой In-memory `FixedWindowRateLimiter` заменён на store-backed лимитер; состояние счётчиков — в новой
системной таблице `public.rate_limit_counters`, общей для всех инстансов core. системной таблице `public.rate_limit_counters`, общей для всех инстансов core.
- **Порт** `Deal.Modules.Tenants.Application.IRateLimitCounterStore`: `IncrementAsync` (окно+приращение → - **Порт** `Deal.Modules.Tenants.Application.IRateLimitCounterStore`: `IncrementAsync` (окно+приращение →
значение), `GetCountAsync`, `ResetAsync`, `DeleteExpiredAsync` (TTL-уборка по `ExpiresAt`). значение), `GetCountAsync`, `ResetAsync`, `DeleteExpiredAsync` (TTL-уборка по `ExpiresAt`).
- **Сущность/конфигурация** `RateLimitCounterEntity` / `RateLimitCounterConfiguration` (public, PK `Key`, - **Сущность/конфигурация** `RateLimitCounterEntity` / `RateLimitCounterConfiguration` (public, PK `Key`,
индекс по `ExpiresAt`), `DealDbContext.RateLimitCounters`. индекс по `ExpiresAt`), `DealDbContext.RateLimitCounters`.
- **EF-адаптер** `RateLimitCounterStore` (Deal.Infrastructure): на Postgres инкремент — один атомарный - **EF-адаптер** `RateLimitCounterStore` (Deal.Infrastructure): на Postgres инкремент — один атомарный
`INSERT … ON CONFLICT ("Key") DO UPDATE … RETURNING "Count"` (raw-команда: EF запрещает композицию по `INSERT … ON CONFLICT ("Key") DO UPDATE … RETURNING "Count"` (raw-команда: EF запрещает композицию по
не-SELECT SQL, поэтому не `SqlQuery`); смена `WindowStart` сбрасывает счётчик (семантика фиксированного не-SELECT SQL, поэтому не `SqlQuery`); смена `WindowStart` сбрасывает счётчик (семантика фиксированного
окна). InMemory-ветка (тесты) — read-modify-write (эталон `TenantLimitStore`). Удаление — `ExecuteDelete` окна). InMemory-ветка (тесты) — read-modify-write (эталон `TenantLimitStore`). Удаление — `ExecuteDelete`
на Npgsql, выгрузка+`RemoveRange` на InMemory. на Npgsql, выгрузка+`RemoveRange` на InMemory.
- **Store-backed лимитер** `Deal.Api.Http.StoreBackedFixedWindowRateLimiter` (подкласс `RateLimiter`): - **Store-backed лимитер** `Deal.Api.Http.StoreBackedFixedWindowRateLimiter` (подкласс `RateLimiter`):
окно выровнено по границам длины (одинаковые окна у всех инстансов/ключей — иначе распределённый лимит окно выровнено по границам длины (одинаковые окна у всех инстансов/ключей — иначе распределённый лимит
несогласован), разрешено ровно `PermitLimit`; хранилище (scoped) резолвится в собственном DI-scope на несогласован), разрешено ровно `PermitLimit`; хранилище (scoped) резолвится в собственном DI-scope на
каждое приобретение через `IServiceScopeFactory`; `IdleDuration` = длина окна (партиции вытесняются). каждое приобретение через `IServiceScopeFactory`; `IdleDuration` = длина окна (партиции вытесняются).
- **Проводка**: `RateLimitPolicies` — партиции `http:{policy}:{ip|tenant}` (`auth`, `api`, глобальный - **Проводка**: `RateLimitPolicies` — партиции `http:{policy}:{ip|tenant}` (`auth`, `api`, глобальный
лимитер — своё пространство `http:global:*`, чтобы именованная и глобальная политики не схлопывали лимитер — своё пространство `http:global:*`, чтобы именованная и глобальная политики не схлопывали
счётчики); `IngressRateLimitInterceptor.CreateLimiter(IServiceScopeFactory, permit)` — партиции счётчики); `IngressRateLimitInterceptor.CreateLimiter(IServiceScopeFactory, permit)` — партиции
`grpc:ingress:{tenant-id}`. Пороги/окна не менялись: auth 10/мин·IP, api/global 600/мин·тенант|IP, `grpc:ingress:{tenant-id}`. Пороги/окна не менялись: auth 10/мин·IP, api/global 600/мин·тенант|IP,
gRPC 600/мин·tenant-id, окно 1 минута, ответ 429/RESOURCE_EXHAUSTED с прежними текстами. gRPC 600/мин·tenant-id, окно 1 минута, ответ 429/RESOURCE_EXHAUSTED с прежними текстами.
- **Уборка** старых окон — фоновая (`DataRetentionScheduler`, см. B4) по TTL `ExpiresAt`. - **Уборка** старых окон — фоновая (`DataRetentionScheduler`, см. B4) по TTL `ExpiresAt`.
## B2. Учёт попыток входа на Postgres ## B2. Учёт попыток входа на Postgres
`Deal.Api.Http.LoginAttemptGuard` переведён с in-memory-`Dictionary` на `IRateLimitCounterStore` `Deal.Api.Http.LoginAttemptGuard` переведён с in-memory-`Dictionary` на `IRateLimitCounterStore`
(ключ `login:{ip}|{login}`), API стал асинхронным (`IsBlockedAsync`/`RecordFailureAsync`/`ResetAsync`); (ключ `login:{ip}|{login}`), API стал асинхронным (`IsBlockedAsync`/`RecordFailureAsync`/`ResetAsync`);
эндпоинты `/api/auth/login` и `/api/operator/auth/login` обновлены. Семантика сохранена 1:1: окно эндпоинты `/api/auth/login` и `/api/operator/auth/login` обновлены. Семантика сохранена 1:1: окно
`LoginAttemptWindowMin` (15 мин), порог `LoginAttemptsMax` (5), сброс при успехе, `Enabled=false` — no-op, `LoginAttemptWindowMin` (15 мин), порог `LoginAttemptsMax` (5), сброс при успехе, `Enabled=false` — no-op,
текст 429 прежний. Гвард зарегистрирован **scoped** (зависит от scoped-хранилища). текст 429 прежний. Гвард зарегистрирован **scoped** (зависит от scoped-хранилища).
## B3. Мгновенный разлогин suspended-сессий ## B3. Мгновенный разлогин suspended-сессий
Выбран наименее рискованный вариант — **проверка статуса тенанта в резолве сессии** (без мутации строк Выбран наименее рискованный вариант — **проверка статуса тенанта в резолве сессии** (без мутации строк
сессий и без изменения проверки 403 на входе): сессий и без изменения проверки 403 на входе):
- `AuthService.ResolveSessionAsync` после разрешения пользователя читает тенанта (`ITenantRepository`) - `AuthService.ResolveSessionAsync` после разрешения пользователя читает тенанта (`ITenantRepository`)
и возвращает null, если статус `suspended`. Сессия проверяется на каждом запросе (SessionMiddleware), и возвращает null, если статус `suspended`. Сессия проверяется на каждом запросе (SessionMiddleware),
поэтому при suspend активные сессии перестают действовать **немедленно**, а не доживают до expiry. поэтому при suspend активные сессии перестают действовать **немедленно**, а не доживают до expiry.
- Флаг состояния не храним: при resume тот же токен вновь разрешается (сессия не удаляется). - Флаг состояния не храним: при resume тот же токен вновь разрешается (сессия не удаляется).
- Impersonation учитывается автоматически — это та же tenant-сессия (маркер оператора). - Impersonation учитывается автоматически — это та же tenant-сессия (маркер оператора).
- Обновлены remarks `TenantStatuses`/`AuthService` (уточнён Ruling 10(5)). - Обновлены remarks `TenantStatuses`/`AuthService` (уточнён Ruling 10(5)).
## B4. Авто-очистки ## B4. Авто-очистки
- **audit_log**: `IAuditLogStore.PurgeOlderThanAsync(cutoff)` (ExecuteDelete на Npgsql). Retention — - **audit_log**: `IAuditLogStore.PurgeOlderThanAsync(cutoff)` (ExecuteDelete на Npgsql). Retention —
настройка `DataRetention:AuditRetentionDays` (дефолт/константа 180 дней; `Enabled` управляет циклом). настройка `DataRetention:AuditRetentionDays` (дефолт/константа 180 дней; `Enabled` управляет циклом).
- **tenant_limits**: отдельной истории периодов в схеме нет (одна строка на тенанта с ленивым reset), - **tenant_limits**: отдельной истории периодов в схеме нет (одна строка на тенанта с ленивым reset),
поэтому реализован `ITenantLimitStore.ResetExpiredPeriodsAsync(now)` — обнуляет накопительные поля поэтому реализован `ITenantLimitStore.ResetExpiredPeriodsAsync(now)` — обнуляет накопительные поля
(`UsedTokens`/`Warned80`/`NotifiedExhausted`) строк с завершившимся периодом и сдвигает `PeriodStart=now`; (`UsedTokens`/`Warned80`/`NotifiedExhausted`) строк с завершившимся периодом и сдвигает `PeriodStart=now`;
идемпотентно. Отдельную таблицу истории не вводили (её нет — «чистить нечего», кроме накоплений). идемпотентно. Отдельную таблицу истории не вводили (её нет — «чистить нечего», кроме накоплений).
- **счётчики**: `IRateLimitCounterStore.DeleteExpiredAsync(now)` убирает окна с истёкшим `ExpiresAt`. - **счётчики**: `IRateLimitCounterStore.DeleteExpiredAsync(now)` убирает окна с истёкшим `ExpiresAt`.
- **Цикл**: `Deal.Api.Hosting.DataRetentionScheduler` (IHostedService, эталон `DealMetricsCollector`): - **Цикл**: `Deal.Api.Hosting.DataRetentionScheduler` (IHostedService, эталон `DealMetricsCollector`):
первый проход через 60 с, далее раз в 24 ч; один scope на проход, in-flight guard, graceful stop, первый проход через 60 с, далее раз в 24 ч; один scope на проход, in-flight guard, graceful stop,
ошибки логируются и не валят хост. Конфигурация — `Deal.Api.Configuration.DataRetentionOptions` ошибки логируются и не валят хост. Конфигурация — `Deal.Api.Configuration.DataRetentionOptions`
(секция `DataRetention`, добавлена в `appsettings.json`), регистрация в `Program.cs`. (секция `DataRetention`, добавлена в `appsettings.json`), регистрация в `Program.cs`.
## Миграции ## Миграции
Системная (public) миграция `20260910172545_RateLimitCounters` Системная (public) миграция `20260910172545_RateLimitCounters`
(`Deal.Infrastructure/Migrations/`, `--context DealDbContext`): таблица `public.rate_limit_counters` (`Deal.Infrastructure/Migrations/`, `--context DealDbContext`): таблица `public.rate_limit_counters`
(`Key` text PK, `WindowStart`/`ExpiresAt` timestamptz, `Count` int) + индекс `IX_rate_limit_counters_ExpiresAt`. (`Key` text PK, `WindowStart`/`ExpiresAt` timestamptz, `Count` int) + индекс `IX_rate_limit_counters_ExpiresAt`.
Применена и проверена на dev-Postgres (:5433): `dotnet ef database update --context DealDbContext` — OK. Применена и проверена на dev-Postgres (:5433): `dotnet ef database update --context DealDbContext` — OK.
## Тесты (+11, все зелёные) ## Тесты (+11, все зелёные)
- `LoginAttemptGuardTests` — переписаны на async + `FakeRateLimitCounterStore` (те же 8 сценариев). - `LoginAttemptGuardTests` — переписаны на async + `FakeRateLimitCounterStore` (те же 8 сценариев).
- `RateLimitCounterStoreTests` (6) — окно/накопление/сброс окна/чтение/Reset/DeleteExpired (InMemory-ветка). - `RateLimitCounterStoreTests` (6) — окно/накопление/сброс окна/чтение/Reset/DeleteExpired (InMemory-ветка).
- `DataRetentionSchedulerTests` (2) — purge аудита, сброс лимитов, уборка счётчиков; идемпотентность. - `DataRetentionSchedulerTests` (2) — purge аудита, сброс лимитов, уборка счётчиков; идемпотентность.
- `AuthServiceTests` +1 — suspend → сессия не разрешается; resume → разрешается. - `AuthServiceTests` +1 — suspend → сессия не разрешается; resume → разрешается.
- `AuditLogStoreTests` +1 — purge по границе; `TenantLimitStoreTests` +1 — сброс только истёкших периодов. - `AuditLogStoreTests` +1 — purge по границе; `TenantLimitStoreTests` +1 — сброс только истёкших периодов.
- Обновлены: `FakeAuditLogStore`/`FakeTenantLimitStore` (новые методы), `FakeRateLimitCounterStore` (новый), - Обновлены: `FakeAuditLogStore`/`FakeTenantLimitStore` (новые методы), `FakeRateLimitCounterStore` (новый),
`OperatorAuthHttpHost`/`RateLimitHttpTests`/`TelegramIngressTestHost` (регистрация фейк-хранилища, `OperatorAuthHttpHost`/`RateLimitHttpTests`/`TelegramIngressTestHost` (регистрация фейк-хранилища,
новый `CreateLimiter`), `AuditServiceTests` (ожидание порта append-only + retention-purge). новый `CreateLimiter`), `AuditServiceTests` (ожидание порта append-only + retention-purge).
**Npgsql-ветка проверена живьём** (временный тест против dev-Postgres, затем удалён): атомарный upsert **Npgsql-ветка проверена живьём** (временный тест против dev-Postgres, затем удалён): атомарный upsert
(RETURNING), `GetCountAsync` (EF `SqlQuery<int>` с алиасом `Value`), `ResetAsync`, `DeleteExpiredAsync` (RETURNING), `GetCountAsync` (EF `SqlQuery<int>` с алиасом `Value`), `ResetAsync`, `DeleteExpiredAsync`
round-trip OK. Это поймало реальную проблему: EF запрещает `SqlQuery` по `INSERT … RETURNING` round-trip OK. Это поймало реальную проблему: EF запрещает `SqlQuery` по `INSERT … RETURNING`
(«non-composable SQL») — исправлено на raw `DbCommand`. («non-composable SQL») — исправлено на raw `DbCommand`.
## Затронутые файлы ## Затронутые файлы
Созданы (core): `Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs`, Созданы (core): `Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs`,
`Deal.Api/Configuration/DataRetentionOptions.cs`, `Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs`, `Deal.Api/Configuration/DataRetentionOptions.cs`, `Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs`,
`Deal.Api/Hosting/DataRetentionScheduler.cs`, `Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs`, `Deal.Api/Hosting/DataRetentionScheduler.cs`, `Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs`,
`Deal.Infrastructure/Persistence/RateLimitCounterConfiguration.cs`, `Deal.Infrastructure/Persistence/RateLimitCounterConfiguration.cs`,
`Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs`, `Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs`,
`Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters(.Designer).cs` + `Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters(.Designer).cs` +
обновлён `DealDbContextModelSnapshot.cs`; тесты — `FakeRateLimitCounterStore`, `RateLimitCounterStoreTests`, обновлён `DealDbContextModelSnapshot.cs`; тесты — `FakeRateLimitCounterStore`, `RateLimitCounterStoreTests`,
`DataRetentionSchedulerTests`. `DataRetentionSchedulerTests`.
Изменено (core): `Deal.Api/Http/LoginAttemptGuard.cs`, `Deal.Api/Middleware/RateLimitPolicies.cs`, Изменено (core): `Deal.Api/Http/LoginAttemptGuard.cs`, `Deal.Api/Middleware/RateLimitPolicies.cs`,
`Deal.Api/Telegram/IngressRateLimitInterceptor.cs`, `Deal.Api/Endpoints/{Auth,OperatorAuth}Endpoints.cs`, `Deal.Api/Telegram/IngressRateLimitInterceptor.cs`, `Deal.Api/Endpoints/{Auth,OperatorAuth}Endpoints.cs`,
`Deal.Api/Program.cs`, `Deal.Api/appsettings.json`, `Deal.Infrastructure/Persistence/DealDbContext.cs`, `Deal.Api/Program.cs`, `Deal.Api/appsettings.json`, `Deal.Infrastructure/Persistence/DealDbContext.cs`,
`.../Repositories/{AuditLogStore,TenantLimitStore}.cs`, `Deal.Infrastructure/ServiceCollectionExtensions.cs`, `.../Repositories/{AuditLogStore,TenantLimitStore}.cs`, `Deal.Infrastructure/ServiceCollectionExtensions.cs`,
`Deal.Modules.Tenants/Application/{AuthService,TenantStatuses,IAuditLogStore,ITenantLimitStore}.cs`. `Deal.Modules.Tenants/Application/{AuthService,TenantStatuses,IAuditLogStore,ITenantLimitStore}.cs`.
## Concerns ## Concerns
- **Дополнительный запрос на запрос** (B3): резолв сессии теперь читает статус тенанта из `public.tenants` - **Дополнительный запрос на запрос** (B3): резолв сессии теперь читает статус тенанта из `public.tenants`
(третий запрос hot-path). Приемлемо для админ-плоскости; при необходимости оптимизируется join'ом в (третий запрос hot-path). Приемлемо для админ-плоскости; при необходимости оптимизируется join'ом в
EF-адаптере сессии (не делал — минимизировал изменения портов). EF-адаптере сессии (не делал — минимизировал изменения портов).
- **Флак фонов собирается не мной**: в тестовом харнессе есть давняя гонка `FreeTcpPort()` (порт - **Флак фонов собирается не мной**: в тестовом харнессе есть давняя гонка `FreeTcpPort()` (порт
освобождается до `StartAsync`), изредка дающая `AddressInUseException` в параллельном прогоне освобождается до `StartAsync`), изредка дающая `AddressInUseException` в параллельном прогоне
(`TelegramIngressTestHost`/`JoinEndpointHttpTests` и др.). Логика пакета B детерминирована; при повторе (`TelegramIngressTestHost`/`JoinEndpointHttpTests` и др.). Логика пакета B детерминирована; при повторе
прогон зелёный. Общий фикс харнесса — отдельная задача. прогон зелёный. Общий фикс харнесса — отдельная задача.
- **Окно выровнено по границам UTC** (B1): тесты, проверяющие «2 разрешены, 3-й 429» в пределах минуты, - **Окно выровнено по границам UTC** (B1): тесты, проверяющие «2 разрешены, 3-й 429» в пределах минуты,
теоретически могут пересечь границу минуты (~доли процента). Сделано осознанно — распределённый лимит теоретически могут пересечь границу минуты (~доли процента). Сделано осознанно — распределённый лимит
требует общих границ окна; in-memory-эталон якорился на момент создания лимитера. требует общих границ окна; in-memory-эталон якорился на момент создания лимитера.
@@ -1,67 +1,67 @@
# Task C1 — фронтенд-производительность (пакет C) # Task C1 — фронтенд-производительность (пакет C)
Дата: 2026-09-10. Каталог работ: `src/frontend` (вне его изменения только ledger/этот отчёт). Дата: 2026-09-10. Каталог работ: `src/frontend` (вне его изменения только ledger/этот отчёт).
## Что сделано ## Что сделано
### 1. Разбиение бандла (устранение предупреждения >500 kB) ### 1. Разбиение бандла (устранение предупреждения >500 kB)
`vite.config.js``build.rollupOptions.output.manualChunks`: `vite.config.js``build.rollupOptions.output.manualChunks`:
- `vendor` — всё из `node_modules` (vue и рантайм); - `vendor` — всё из `node_modules` (vue и рантайм);
- `i18n` — словари локалей `src/i18n/locales/**` (`ru.js`, `ru.data.js`); - `i18n` — словари локалей `src/i18n/locales/**` (`ru.js`, `ru.data.js`);
- `app` — остальной код приложения (default-чанк). - `app` — остальной код приложения (default-чанк).
Словарь остаётся в **статическом** графе импортов (нужен на первом рендере) — вынос в Словарь остаётся в **статическом** графе импортов (нужен на первом рендере) — вынос в
отдельный чанк не делает его ленивым и не ломает загрузку: браузер тянет `i18n`-чанк отдельный чанк не делает его ленивым и не ломает загрузку: браузер тянет `i18n`-чанк
параллельно основному. `build.chunkSizeWarningLimit` **не повышался**. параллельно основному. `build.chunkSizeWarningLimit` **не повышался**.
Размеры чанков (minified, gzip): Размеры чанков (minified, gzip):
| Чанк | До | После | | Чанк | До | После |
|---------------------|------------|------------| |---------------------|------------|------------|
| `index` (app) | 501.74 kB (144.90 kB) | **309.02 kB (78.89 kB)** | | `index` (app) | 501.74 kB (144.90 kB) | **309.02 kB (78.89 kB)** |
| `vendor` | — | 79.59 kB (31.54 kB) | | `vendor` | — | 79.59 kB (31.54 kB) |
| `i18n` | — | 113.65 kB (34.35 kB) | | `i18n` | — | 113.65 kB (34.35 kB) |
| `OperatorConsole` | 53.49 kB | 53.55 kB | | `OperatorConsole` | 53.49 kB | 53.55 kB |
| `JoinView` | 6.51 kB | 6.57 kB | | `JoinView` | 6.51 kB | 6.57 kB |
| `index.css` | 63.01 kB | 63.04 kB | | `index.css` | 63.01 kB | 63.04 kB |
Итог: предупреждение `Some chunks are larger than 500 kB` ушло, сборка зелёная. Итог: предупреждение `Some chunks are larger than 500 kB` ушло, сборка зелёная.
### 2. Прогрессивный рендер длинных колонок ### 2. Прогрессивный рендер длинных колонок
- Новый переиспользуемый примитив `src/composables/progressive.js` - Новый переиспользуемый примитив `src/composables/progressive.js`
`useProgressiveList(source, { pageSize = 100 })`. Рендерит первые `pageSize` `useProgressiveList(source, { pageSize = 100 })`. Рендерит первые `pageSize`
элементов, считает число скрытых, отдаёт `showMore()` (добавляет следующую порцию). элементов, считает число скрытых, отдаёт `showMore()` (добавляет следующую порцию).
Лимит автоматически сжимается при уменьшении списка (очистка колонки/фильтр). Лимит автоматически сжимается при уменьшении списка (очистка колонки/фильтр).
- Единственное место рендера списка карточек — `ContainerColumn.vue` - Единственное место рендера списка карточек — `ContainerColumn.vue`
(`<Card v-for>`, строка ~365). Он общий для канбана дашборда и стадий «Выбранных», (`<Card v-for>`, строка ~365). Он общий для канбана дашборда и стадий «Выбранных»,
поэтому дублей нет. Заменено на `visibleCards` + кнопка «Показать ещё N» при поэтому дублей нет. Заменено на `visibleCards` + кнопка «Показать ещё N» при
`hasMore`. Кнопка использует существующий стиль (как в `ProcessingView`). `hasMore`. Кнопка использует существующий стиль (как в `ProcessingView`).
- Внешний вид и тексты 1:1 при малых списках: кнопка и срез появляются только - Внешний вид и тексты 1:1 при малых списках: кнопка и срез появляются только
при числе карточек > 100. Тяжёлая виртуализация не вводилась. при числе карточек > 100. Тяжёлая виртуализация не вводилась.
- Новая строка — только через словарь: `common.showMoreCount` = «Показать ещё {count}». - Новая строка — только через словарь: `common.showMoreCount` = «Показать ещё {count}».
### 3. Линтер i18n ### 3. Линтер i18n
`npm run lint:i18n` рабочий и зелёный. В `npm run build` **не добавлен** (по заданию). `npm run lint:i18n` рабочий и зелёный. В `npm run build` **не добавлен** (по заданию).
Скрипт уже был в `package.json`; добавлен `src/frontend/README.md` с описанием команд, Скрипт уже был в `package.json`; добавлен `src/frontend/README.md` с описанием команд,
локализации, разбиения бандла и прогрессивного рендера. локализации, разбиения бандла и прогрессивного рендера.
## Проверка ## Проверка
``` ```
npm run build → ✓ built, без предупреждения >500 kB npm run build → ✓ built, без предупреждения >500 kB
app 309.02 kB, vendor 79.59 kB, i18n 113.65 kB app 309.02 kB, vendor 79.59 kB, i18n 113.65 kB
npm run lint:i18n → ✓ кириллических пользовательских строк вне словарей не найдено npm run lint:i18n → ✓ кириллических пользовательских строк вне словарей не найдено
``` ```
Новых зависимостей нет. Dev-сервер не поднимался, порт :5173 свободен (проверено Новых зависимостей нет. Dev-сервер не поднимался, порт :5173 свободен (проверено
`netstat`; запущенных процессов не осталось). `netstat`; запущенных процессов не осталось).
## Что осталось (вне фронта, пакет C) ## Что осталось (вне фронта, пакет C)
- `telegram-service`: LRU-кэши WTelegram. - `telegram-service`: LRU-кэши WTelegram.
- Механизм миграций на 1000 схем. - Механизм миграций на 1000 схем.
- Включение `lint:i18n` в общий прогон `scripts/test.sh` (делает отдельный агент). - Включение `lint:i18n` в общий прогон `scripts/test.sh` (делает отдельный агент).
@@ -1,91 +1,91 @@
# Task C2 — производительность бэк/сервисы/скрипты (пакет C) # Task C2 — производительность бэк/сервисы/скрипты (пакет C)
Дата: 2026-09-10. Каталог работ: `src/telegram-service`, `src/core`, `scripts`. Фронт Дата: 2026-09-10. Каталог работ: `src/telegram-service`, `src/core`, `scripts`. Фронт
(`src/frontend`) не затронут — его часть пакета закрыта в `task-c1-report.md`. (`src/frontend`) не затронут — его часть пакета закрыта в `task-c1-report.md`.
## Что сделано ## Что сделано
### 1. LRU-кэши WTelegram (telegram-service) ### 1. LRU-кэши WTelegram (telegram-service)
Найдены неограниченные словари WTelegram-клиента в `WTelegramSessionClient` (растут всю жизнь Найдены неограниченные словари WTelegram-клиента в `WTelegramSessionClient` (растут всю жизнь
процесса: access_hash, чаты/каналы, пользователи — накопительно по всем диалогам/поискам/discovery): процесса: access_hash, чаты/каналы, пользователи — накопительно по всем диалогам/поискам/discovery):
| Было | Стало | Ёмкость (именованная константа) | | Было | Стало | Ёмкость (именованная константа) |
|---|---|---| |---|---|---|
| `Dictionary<string, long> _entityAccessHashes` | `LruCache<string, long>` | `AccessHashCacheCapacity = 2000` | | `Dictionary<string, long> _entityAccessHashes` | `LruCache<string, long>` | `AccessHashCacheCapacity = 2000` |
| `Dictionary<long, ChatBase> _chatsById` | `LruCache<long, ChatBase>` | `ChatEntityCacheCapacity = 1000` | | `Dictionary<long, ChatBase> _chatsById` | `LruCache<long, ChatBase>` | `ChatEntityCacheCapacity = 1000` |
| `Dictionary<long, User> _usersById` | `LruCache<long, User>` | `UserEntityCacheCapacity = 2000` | | `Dictionary<long, User> _usersById` | `LruCache<long, User>` | `UserEntityCacheCapacity = 2000` |
- Новый тип `Deal.Telegram/Caching/LruCache.cs` — LRU фиксированной ёмкости - Новый тип `Deal.Telegram/Caching/LruCache.cs` — LRU фиксированной ёмкости
(Dictionary + LinkedList недавности, `Set`/`TryGetValue`/`ContainsKey`/`Count`/`Capacity`, (Dictionary + LinkedList недавности, `Set`/`TryGetValue`/`ContainsKey`/`Count`/`Capacity`,
`Touch` при чтении и обновлении). Не потокобезопасен намеренно: все обращения в клиенте уже `Touch` при чтении и обновлении). Не потокобезопасен намеренно: все обращения в клиенте уже
сериализованы общим `_entityCacheGate` (комментарий в XML-doc типа это фиксирует). сериализованы общим `_entityCacheGate` (комментарий в XML-doc типа это фиксирует).
- Поведение сохранено: кэш — только оптимизация. Вытеснение access_hash уводит `ResolvePeerAsync` - Поведение сохранено: кэш — только оптимизация. Вытеснение access_hash уводит `ResolvePeerAsync`
на прежний фолбэк (доливка `GetDialogsAsync` со страницей `DialogResolvePageSize`), вытеснение на прежний фолбэк (доливка `GetDialogsAsync` со страницей `DialogResolvePageSize`), вытеснение
сущностей чата/пользователя — на прежний сетевой запрос `GetFullChannel`/`GetFullChat`. сущностей чата/пользователя — на прежний сетевой запрос `GetFullChannel`/`GetFullChat`.
- `TryImportPeerFromCollector`/`BuildPeer`/`ResolveInputChannelAsync`/`CacheEntities` переведены - `TryImportPeerFromCollector`/`BuildPeer`/`ResolveInputChannelAsync`/`CacheEntities` переведены
на API LRU (`Set` вместо индексатора); контракты gRPC не менялись. на API LRU (`Set` вместо индексатора); контракты gRPC не менялись.
- Обновлённые/новые unit-тесты: `Deal.Telegram.Tests/LruCacheTests.cs` (6 кейсов: вытеснение - Обновлённые/новые unit-тесты: `Deal.Telegram.Tests/LruCacheTests.cs` (6 кейсов: вытеснение
least-recently-used при переполнении, обновление недавности на чтении/записи, промах, least-recently-used при переполнении, обновление недавности на чтении/записи, промах,
`ContainsKey` без вытеснения, reject неположительной ёмкости, ёмкость 1). `ContainsKey` без вытеснения, reject неположительной ёмкости, ёмкость 1).
### 2. Механизм миграций на 1000 схем (SaaS) ### 2. Механизм миграций на 1000 схем (SaaS)
**Идемпотентность подтверждена (менять не потребовалось):** `TenantProvisioningService` уже **Идемпотентность подтверждена (менять не потребовалось):** `TenantProvisioningService` уже
выполняет `CREATE SCHEMA IF NOT EXISTS` и `TenantDbContext.Database.MigrateAsync`, а EF Core выполняет `CREATE SCHEMA IF NOT EXISTS` и `TenantDbContext.Database.MigrateAsync`, а EF Core
сверяется с таблицей истории `__TenantMigrationsHistory` схемы тенанта и применяет **только сверяется с таблицей истории `__TenantMigrationsHistory` схемы тенанта и применяет **только
неприменённые** миграции. Повторный bootstrap/прогон ничего не переприменяет. неприменённые** миграции. Повторный bootstrap/прогон ничего не переприменяет.
Добавлено недостающее — пакетная (maintenance) миграция всех существующих схем: Добавлено недостающее — пакетная (maintenance) миграция всех существующих схем:
- `Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs` — обходит реестр - `Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs` — обходит реестр
(`ITenantRepository.ListAsync`) через `Parallel.ForEachAsync` с ограничением параллелизма (`ITenantRepository.ListAsync`) через `Parallel.ForEachAsync` с ограничением параллелизма
(`DefaultMaxParallelism = 4`, кламп `[1..32]`), логирует прогресс «{Done}/{Total} — {Schema} готова». (`DefaultMaxParallelism = 4`, кламп `[1..32]`), логирует прогресс «{Done}/{Total} — {Schema} готова».
Сбой одной схемы не прерывает остальные: ошибка логируется, схема попадает в `failedSchemas`. Сбой одной схемы не прерывает остальные: ошибка логируется, схема попадает в `failedSchemas`.
- `Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs` — сводка - `Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs` — сводка
`{Ok, Total, Migrated, Failed, DurationMs, FailedSchemas}`. `{Ok, Total, Migrated, Failed, DurationMs, FailedSchemas}`.
- `Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs``POST /api/operator/maintenance/tenants/migrate` - `Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs``POST /api/operator/maintenance/tenants/migrate`
(только оператор, 401 без сессии), ответ `{ok, total, migrated, failed, failedSchemas, durationMs}`. (только оператор, 401 без сессии), ответ `{ok, total, migrated, failed, failedSchemas, durationMs}`.
- `TenantBootstrapService` переведён на тот же сервис: провижининг схем всех тенантов при старте - `TenantBootstrapService` переведён на тот же сервис: провижининг схем всех тенантов при старте
стал параллельным с прогресс-логом (раньше — последовательный `foreach` без логов). Bootstrap стал параллельным с прогресс-логом (раньше — последовательный `foreach` без логов). Bootstrap
не сломан: dev-seed дефолтного тенанта/admin и its «всегда провижинить реестр» сохранены, не сломан: dev-seed дефолтного тенанта/admin и its «всегда провижинить реестр» сохранены,
идемпотентность та же. идемпотентность та же.
- Регистрация: `AddDealPersistence``AddScoped<TenantSchemaMigrationService>`; ручка смонтирована - Регистрация: `AddDealPersistence``AddScoped<TenantSchemaMigrationService>`; ручка смонтирована
в `Program.cs`. в `Program.cs`.
- Тесты: `TenantSchemaMigrationServiceTests.cs` (пустой реестр, миграция всех схем, сбой одной - Тесты: `TenantSchemaMigrationServiceTests.cs` (пустой реестр, миграция всех схем, сбой одной
схемы не прерывает остальные) + `OperatorMaintenanceEndpointsHttpTests.cs` (401 без оператора, схемы не прерывает остальные) + `OperatorMaintenanceEndpointsHttpTests.cs` (401 без оператора,
сводка 200 на двух тенантах). Тест-дубль `FailingTenantProvisioner`; `FakeTenantProvisioner` сводка 200 на двух тенантах). Тест-дубль `FailingTenantProvisioner`; `FakeTenantProvisioner`
сделан потокобезопасным (параллельный прогон). сделан потокобезопасным (параллельный прогон).
### 3. Линтер i18n в общий прогон ### 3. Линтер i18n в общий прогон
`scripts/test.sh` был минимальным (только `dotnet test` core). Расширен: `scripts/test.sh` был минимальным (только `dotnet test` core). Расширен:
`dotnet test tests/Deal.Tests.Unit` → затем `npm run lint:i18n` из `src/frontend`. `dotnet test tests/Deal.Tests.Unit` → затем `npm run lint:i18n` из `src/frontend`.
Если `npm` не установлен — шаг мягко пропускается с сообщением (не фатально); при наличии npm Если `npm` не установлен — шаг мягко пропускается с сообщением (не фатально); при наличии npm
падение линтера валит прогон (это и есть гейт). `cd` выполняется в подоболочке, рабочая падение линтера валит прогон (это и есть гейт). `cd` выполняется в подоболочке, рабочая
директория вызывающего не меняется; сохранён POSIX-sh стиль скрипта (`sh -n` — OK). директория вызывающего не меняется; сохранён POSIX-sh стиль скрипта (`sh -n` — OK).
## Проверка ## Проверка
``` ```
src/telegram-service: src/telegram-service:
dotnet build Deal.Telegram.sln -v q --nologo → 0 warnings / 0 errors dotnet build Deal.Telegram.sln -v q --nologo → 0 warnings / 0 errors
dotnet test Deal.Telegram.sln → всего 125; сбой 0; успешно 125 (+6) dotnet test Deal.Telegram.sln → всего 125; сбой 0; успешно 125 (+6)
src/core: src/core:
dotnet build Deal.sln -v q --nologo → 0 warnings / 0 errors dotnet build Deal.sln -v q --nologo → 0 warnings / 0 errors
dotnet test tests/Deal.Tests.Unit/…csproj → всего 1189; сбой 0; успешно 1189 (+5) dotnet test tests/Deal.Tests.Unit/…csproj → всего 1189; сбой 0; успешно 1189 (+5)
scripts: scripts:
sh -n scripts/test.sh → syntax ok sh -n scripts/test.sh → syntax ok
sh scripts/test.sh → core 1189/1189 + ✓ i18n + «TEST ОК» sh scripts/test.sh → core 1189/1189 + ✓ i18n + «TEST ОК»
``` ```
Docker/Postgres не поднимались; запущенных процессов и контейнеров не осталось Docker/Postgres не поднимались; запущенных процессов и контейнеров не осталось
(`dotnet build-server shutdown` выполнен). (`dotnet build-server shutdown` выполнен).
## Что осталось (вне этой задачи) ## Что осталось (вне этой задачи)
- Пакет D (reclassify-проводка + учёт токенов ML). - Пакет D (reclassify-проводка + учёт токенов ML).
- Опционально (продуктовое решение): вынести параллелизм пакетной миграции в конфиг/env вместо - Опционально (продуктовое решение): вынести параллелизм пакетной миграции в конфиг/env вместо
константы `DefaultMaxParallelism`; курсор/шардирование обхода реестра для многих тысяч схем. константы `DefaultMaxParallelism`; курсор/шардирование обхода реестра для многих тысяч схем.
@@ -1,68 +1,68 @@
# Task: Cleanup мусора (leadradar-legacy) # Task: Cleanup мусора (leadradar-legacy)
Дата: 2026-09-11 Дата: 2026-09-11
Корень: `C:\telbase` (не git — удаления необратимы, работал консервативно). Корень: `C:\telbase` (не git — удаления необратимы, работал консервативно).
## Инвентаризация (до) ## Инвентаризация (до)
`du -sh .` = **498M** `du -sh .` = **498M**
Крупные каталоги: Крупные каталоги:
| Путь | Размер | | Путь | Размер |
|---|---| |---|---|
| `src/` | 424M | | `src/` | 424M |
| `data/` | 68M | | `data/` | 68M |
| `.superpowers/` | 2.7M | | `.superpowers/` | 2.7M |
| `Стиль_кода.docx` | 1.5M | | `Стиль_кода.docx` | 1.5M |
| `archive/` | 1.2M | | `archive/` | 1.2M |
| `docs/` | 932K | | `docs/` | 932K |
Артефакты сборки .NET (`bin/`+`obj/` под `src/**`, кроме `node_modules`): суммарно **359M**. Артефакты сборки .NET (`bin/`+`obj/` под `src/**`, кроме `node_modules`): суммарно **359M**.
Корневые легаси-файлы прототипа LeadRadar: `cookies.txt`, `settings.json`, `.env`, `.env.example` (env c префиксом `LEADRADAR_*`). Корневые легаси-файлы прототипа LeadRadar: `cookies.txt`, `settings.json`, `.env`, `.env.example` (env c префиксом `LEADRADAR_*`).
Логи рабочей директории: `src/frontend/vite-dev.log`, `vite-dev.out.log`, `vite-dev.err.log` (~172K). Логи рабочей директории: `src/frontend/vite-dev.log`, `vite-dev.out.log`, `vite-dev.err.log` (~172K).
## Выполнено ## Выполнено
### Удалено (безопасно) ### Удалено (безопасно)
- Все **44** каталога `bin/`/`obj/` под `src/**` от .NET-проектов (ai-service, contracts, core/*, grpc-hosting, ml-service, telegram-service), кроме `bin/` внутри `node_modules` — их не трогал. - Все **44** каталога `bin/`/`obj/` под `src/**` от .NET-проектов (ai-service, contracts, core/*, grpc-hosting, ml-service, telegram-service), кроме `bin/` внутри `node_modules` — их не трогал.
- `cookies.txt` (дамп Netscape cookies, `leadradar_session`). - `cookies.txt` (дамп Netscape cookies, `leadradar_session`).
- `settings.json` (дамп настроек прототипа LeadRadar: aiProvider/aiPrompt и т.п.). - `settings.json` (дамп настроек прототипа LeadRadar: aiProvider/aiPrompt и т.п.).
- `src/frontend/vite-dev.log`, `vite-dev.out.log`, `vite-dev.err.log`. - `src/frontend/vite-dev.log`, `vite-dev.out.log`, `vite-dev.err.log`.
### Перенесено в архив (обратимо, не удалено) ### Перенесено в архив (обратимо, не удалено)
- `data/``archive/leadradar-legacy/data/` (68M: `leadradar.duckdb` ~61M, `leadradar.db`, `leads_dump.json`, `ml/ml.duckdb`, `style_doc.txt`, `_t.py`, `telegram_sessions/`, `logs/`, `deal-emit/`, `minio/`, `encryption.key`, `attachments/`, `backups/`). - `data/``archive/leadradar-legacy/data/` (68M: `leadradar.duckdb` ~61M, `leadradar.db`, `leads_dump.json`, `ml/ml.duckdb`, `style_doc.txt`, `_t.py`, `telegram_sessions/`, `logs/`, `deal-emit/`, `minio/`, `encryption.key`, `attachments/`, `backups/`).
- `.env``archive/leadradar-legacy/.env` - `.env``archive/leadradar-legacy/.env`
- `.env.example``archive/leadradar-legacy/.env.example` - `.env.example``archive/leadradar-legacy/.env.example`
Актуальные env — `deploy/compose*.yml` и `deploy/.env*.example` — не затронуты. Актуальные env — `deploy/compose*.yml` и `deploy/.env*.example` — не затронуты.
## Оставлено осознанно ## Оставлено осознанно
- **`src/frontend/dist/`** — артефакт сборки фронта, но в задаче явно не перечислен. Оставлен (консервативно); пересобирается `npm run build`. - **`src/frontend/dist/`** — артефакт сборки фронта, но в задаче явно не перечислен. Оставлен (консервативно); пересобирается `npm run build`.
- `src/frontend/node_modules` — нужен для запуска/сборки фронта. - `src/frontend/node_modules` — нужен для запуска/сборки фронта.
- `docs/`, `.superpowers/`, `archive/`, `deploy/`, `scripts/`, `ТЗ.md`, `Стиль_кода.docx`, `README.md`, `.editorconfig`, `.dockerignore` — по условию не трогать. - `docs/`, `.superpowers/`, `archive/`, `deploy/`, `scripts/`, `ТЗ.md`, `Стиль_кода.docx`, `README.md`, `.editorconfig`, `.dockerignore` — по условию не трогать.
- `node_modules/.cache` — отсутствует (нечего удалять). - `node_modules/.cache` — отсутствует (нечего удалять).
- `TestResults/`, `*.user`, `*.suo`, `*.nupkg`, `*.tmp`, `*.bak`, `*.orig`, `*~`, `~$*`, `.DS_Store`, `Thumbs.db` — не найдены. - `TestResults/`, `*.user`, `*.suo`, `*.nupkg`, `*.tmp`, `*.bak`, `*.orig`, `*~`, `~$*`, `.DS_Store`, `Thumbs.db` — не найдены.
## Итог по размеру ## Итог по размеру
| | До | После | | | До | После |
|---|---|---| |---|---|---|
| Корень `C:\telbase` | 498M | **140M** | | Корень `C:\telbase` | 498M | **140M** |
| `src/` | 424M | 66M | | `src/` | 424M | 66M |
| `archive/` | 1.2M | 69M (данные перенесены, не удалены) | | `archive/` | 1.2M | 69M (данные перенесены, не удалены) |
**Освобождено ~358M.** **Освобождено ~358M.**
## Результаты валидации ## Результаты валидации
- `cd src/core && dotnet build Deal.sln -v q --nologo` → успешно (0 warnings / 0 errors). - `cd src/core && dotnet build Deal.sln -v q --nologo` → успешно (0 warnings / 0 errors).
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --nologo`**1275/1275 PASS**, сбой 0, пропущено 0. - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --nologo`**1275/1275 PASS**, сбой 0, пропущено 0.
- `cd src/frontend && npm run build``✓ built in 1.78s` (111 modules). - `cd src/frontend && npm run build``✓ built in 1.78s` (111 modules).
- `npm run lint:i18n``✓ кириллических пользовательских строк вне словарей не найдено`. - `npm run lint:i18n``✓ кириллических пользовательских строк вне словарей не найдено`.
- `dotnet build-server shutdown` → MSBuild/Roslyn серверы остановлены. - `dotnet build-server shutdown` → MSBuild/Roslyn серверы остановлены.
- `docker ps` → контейнеров нет, хвостов не осталось. - `docker ps` → контейнеров нет, хвостов не осталось.
## Вывод ## Вывод
Проект очищен от build-артефактов и легаси прототипа LeadRadar. Ничего необратимо важного не удалено: данные и env прототипа перемещены в `archive/leadradar-legacy/`. Сборка .NET, тесты и фронтенд — зелёные. Проект очищен от build-артефактов и легаси прототипа LeadRadar. Ничего необратимо важного не удалено: данные и env прототипа перемещены в `archive/leadradar-legacy/`. Сборка .NET, тесты и фронтенд — зелёные.
@@ -1,115 +1,115 @@
# Task D report — reclassify на реальном ИИ + учёт токенов ML (пакет D) # Task D report — reclassify на реальном ИИ + учёт токенов ML (пакет D)
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (§Пакет D). План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (§Пакет D).
Контракт: `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify). Контракт: `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify).
## Что сделано ## Что сделано
### 1. Реальная переклассификация (batch + одиночная) ### 1. Реальная переклассификация (batch + одиночная)
Новый сервис `Deal.Modules.Pipeline/Application/CardReclassifier.cs` прогоняет карточку(и) через **тот же Новый сервис `Deal.Modules.Pipeline/Application/CardReclassifier.cs` прогоняет карточку(и) через **тот же
конвейер**, что и «filtered»-проход воркера (прототип `leads.py reclassify_lead L292389`), но **без создания конвейер**, что и «filtered»-проход воркера (прототип `leads.py reclassify_lead L292389`), но **без создания
новой карточки** — обновляется существующая: новой карточки** — обновляется существующая:
1. фильтр `IAiClassifier.FilterAsync` (при `aiEnabled`; уважает выключатель `aiFilterEnabled`, сбой → пропуск); 1. фильтр `IAiClassifier.FilterAsync` (при `aiEnabled`; уважает выключатель `aiFilterEnabled`, сбой → пропуск);
2. классификация `IAiClassifier.ClassifyAsync` (успех → `is_vacancy_known=true`; сбой → локальный разбор); 2. классификация `IAiClassifier.ClassifyAsync` (успех → `is_vacancy_known=true`; сбой → локальный разбор);
3. спам/непройденный фильтр → корзина + сигнал ML «спам» (вес ИИ 0.4); 3. спам/непройденный фильтр → корзина + сигнал ML «спам» (вес ИИ 0.4);
4. сборка контента `CardComposer` (заголовок/«О заявке»/стек/бюджет+конверсия/контакты) и страховка 4. сборка контента `CardComposer` (заголовок/«О заявке»/стек/бюджет+конверсия/контакты) и страховка
`ColumnRules.ContainerAccepts` (доска из разбора принимается только если текст прошёл её правила); `ColumnRules.ContainerAccepts` (доска из разбора принимается только если текст прошёл её правила);
5. атомарное обновление карточки одним запросом; 5. атомарное обновление карточки одним запросом;
6. обучающие сигналы ML (доска свободной колонки + тип `t:hire`/`t:order`) — как в пайплайне. 6. обучающие сигналы ML (доска свободной колонки + тип `t:hire`/`t:order`) — как в пайплайне.
**Без кредов/ИИ не падает:** когда `aiEnabled=false` или классификатор недоступен/сбой — детерминированный **Без кредов/ИИ не падает:** когда `aiEnabled=false` или классификатор недоступен/сбой — детерминированный
локальный разбор `LocalFieldsParser` + `AiCardMapper.FromLocal` (ветка воркера `aiEnabled=false`/`aiFail`), локальный разбор `LocalFieldsParser` + `AiCardMapper.FromLocal` (ветка воркера `aiEnabled=false`/`aiFail`),
ответ помечается `usedAi=false`. ответ помечается `usedAi=false`.
**Одиночная переклассификация добавлена** (в контракте есть `POST /api/cards/{cardId}/reclassify`, но роута **Одиночная переклассификация добавлена** (в контракте есть `POST /api/cards/{cardId}/reclassify`, но роута
не было): любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`. не было): любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
**Одна переклассификация за раз**`ReclassifyGate` (singleton, `SemaphoreSlim`, неблокирующий вход): занятый **Одна переклассификация за раз**`ReclassifyGate` (singleton, `SemaphoreSlim`, неблокирующий вход): занятый
проход отвечает `{started:false, busy:true}`, как фоновая задача прототипа. проход отвечает `{started:false, busy:true}`, как фоновая задача прототипа.
### 2. Переиспользование без дублей ### 2. Переиспользование без дублей
- `ICardStore.ApplyReclassificationAsync(CardReclassificationDto, ct)` — одно обновление полей классификации - `ICardStore.ApplyReclassificationAsync(CardReclassificationDto, ct)` — одно обновление полей классификации
(`leads.py L346367`): col/is_new/тип/title/summary/stack/budget/converted/contact/contacts/matchHits. (`leads.py L346367`): col/is_new/тип/title/summary/stack/budget/converted/contact/contacts/matchHits.
Реализация — `KanbanStore.Cards.cs` (+ `FakeKanjStore`). Реализация — `KanbanStore.Cards.cs` (+ `FakeKanjStore`).
- Обучение ML вынесено из воркера в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация); - Обучение ML вынесено из воркера в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация);
поведение воркера не изменилось. поведение воркера не изменилось.
- Вес сигнала ИИ централизован: `MlLearningLabels.AiPushWeight = 0.4` (был приватный const в воркере). - Вес сигнала ИИ централизован: `MlLearningLabels.AiPushWeight = 0.4` (был приватный const в воркере).
- Корзина — через существующий `CardsService.TrashCardAsync`; добавлен параметр `teach` (1:1 с прототипом - Корзина — через существующий `CardsService.TrashCardAsync`; добавлен параметр `teach` (1:1 с прототипом
`trash_lead(teach=...)`): `teach=false` — журнал `action=trash` пишется, а сигнал «спам» кладёт `trash_lead(teach=...)`): `teach=false` — журнал `action=trash` пишется, а сигнал «спам» кладёт
переклассификация явно с весом ИИ 0.4 (вместо 1.0 действия пользователя). переклассификация явно с весом ИИ 0.4 (вместо 1.0 действия пользователя).
### 3. Итоговая форма ответа (совместимо расширена) ### 3. Итоговая форма ответа (совместимо расширена)
Сохранены: `started`, `busy`, `attempted`. Добавлено: `reclassified`, `moved`, `kept`, `trashed`, `skipped`, Сохранены: `started`, `busy`, `attempted`. Добавлено: `reclassified`, `moved`, `kept`, `trashed`, `skipped`,
`usedAi`, `reason`. `usedAi`, `reason`.
```json ```json
{ {
"started": true, "busy": false, "attempted": 3, "reclassified": 3, "started": true, "busy": false, "attempted": 3, "reclassified": 3,
"moved": 1, "kept": 1, "trashed": 1, "skipped": 0, "usedAi": false, "reason": null "moved": 1, "kept": 1, "trashed": 1, "skipped": 0, "usedAi": false, "reason": null
} }
``` ```
Фронт (`reclassifyInbox`) читает только `started/busy/attempted/reason` — контракт совместим: `started=true` Фронт (`reclassifyInbox`) читает только `started/busy/attempted/reason` — контракт совместим: `started=true`
по-прежнему включает тост и перезагрузку доски. Зафиксировано в контрактном документе (только раздел reclassify). по-прежнему включает тост и перезагрузку доски. Зафиксировано в контрактном документе (только раздел reclassify).
Дополнительно: аудит `AuditEvents.CardReclassified = "card_reclassified"` (пишется только при Дополнительно: аудит `AuditEvents.CardReclassified = "card_reclassified"` (пишется только при
`reclassified > 0`). `reclassified > 0`).
## Статус учёта токенов ML ## Статус учёта токенов ML
Проверено, **всё уже покрыто** (код не менялся): Проверено, **всё уже покрыто** (код не менялся):
| Путь | Учёт | Где | | Путь | Учёт | Где |
|---|---|---| |---|---|---|
| Платный ИИ (Filter/Classify/…) | бюджет `tenant_limits` + lifetime KV `aiTokenUsage` + событие `kind=ai` + метрики `deal.ai.*` | `GrpcAiClassifier`/`GrpcAiTools``TokenUsageRecorder.AddAsync` | | Платный ИИ (Filter/Classify/…) | бюджет `tenant_limits` + lifetime KV `aiTokenUsage` + событие `kind=ai` + метрики `deal.ai.*` | `GrpcAiClassifier`/`GrpcAiTools``TokenUsageRecorder.AddAsync` |
| **reclassify через ИИ** | покрыт автоматически — идёт через тот же `IAiClassifier` (адаптер сам пишет usage) | `GrpcAiClassifier` | | **reclassify через ИИ** | покрыт автоматически — идёт через тот же `IAiClassifier` (адаптер сам пишет usage) | `GrpcAiClassifier` |
| Реальный ML-predict (gRPC) | событие `kind=ml` (оценка ≈chars/4) + метрики `deal.ml.calls`/`deal.ml.tokens` | `GrpcMlClient.PredictAsync``TokenUsageRecorder.AddEstimatedAsync` | | Реальный ML-predict (gRPC) | событие `kind=ml` (оценка ≈chars/4) + метрики `deal.ml.calls`/`deal.ml.tokens` | `GrpcMlClient.PredictAsync``TokenUsageRecorder.AddEstimatedAsync` |
| Локальные ИИ/ML-адаптеры | **не пишут** — осознанное решение этапа 10 (dev-заглушки бесплатны) | `LocalAiClassifier`/`LocalMlClient` | | Локальные ИИ/ML-адаптеры | **не пишут** — осознанное решение этапа 10 (dev-заглушки бесплатны) | `LocalAiClassifier`/`LocalMlClient` |
| Батчевые предсказания | в core нет batch-predict; predict идёт по сообщению (pump/Discovery/manual) | запись на каждый вызов | | Батчевые предсказания | в core нет batch-predict; predict идёт по сообщению (pump/Discovery/manual) | запись на каждый вызов |
Переклассификация **не делает ML-predict** (как и прототип `reclassify_lead`) — только классификацию и Переклассификация **не делает ML-predict** (как и прототип `reclassify_lead`) — только классификацию и
обучающие `push` (очередь обучения, токенами не тарифицируется). Отдельного «reclassify-ML»-учёта не требуется. обучающие `push` (очередь обучения, токенами не тарифицируется). Отдельного «reclassify-ML»-учёта не требуется.
## Файлы ## Файлы
Создано: Создано:
- `Deal.Modules.Pipeline/Application/CardReclassifier.cs` - `Deal.Modules.Pipeline/Application/CardReclassifier.cs`
- `Deal.Modules.Pipeline/Application/AiCardLearning.cs` - `Deal.Modules.Pipeline/Application/AiCardLearning.cs`
- `Deal.Modules.Pipeline/Application/ReclassifyGate.cs` - `Deal.Modules.Pipeline/Application/ReclassifyGate.cs`
- `Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs` - `Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs`
- `Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs` - `Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs`
- `tests/Deal.Tests.Unit/CardReclassifierTests.cs` - `tests/Deal.Tests.Unit/CardReclassifierTests.cs`
Изменено: Изменено:
- `Deal.Contracts/Integrations/MlLearningLabels.cs` (+`AiPushWeight`) - `Deal.Contracts/Integrations/MlLearningLabels.cs` (+`AiPushWeight`)
- `Deal.Modules.Pipeline/Application/PipelineWorkerService.cs`/`.Learning.cs`/`.Pump.cs` (общий `AiCardLearning`, - `Deal.Modules.Pipeline/Application/PipelineWorkerService.cs`/`.Learning.cs`/`.Pump.cs` (общий `AiCardLearning`,
централизованный вес; поведение воркера не изменилось) централизованный вес; поведение воркера не изменилось)
- `Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs` (DI) - `Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs` (DI)
- `Deal.Modules.Kanban/Application/ICardStore.cs` + `CardsService.Operations.cs` (`teach`) - `Deal.Modules.Kanban/Application/ICardStore.cs` + `CardsService.Operations.cs` (`teach`)
- `Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs` - `Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs`
- `Deal.Api/Endpoints/CardsEndpoints.cs` (+роут `/{cardId}/reclassify`, реальный batch) - `Deal.Api/Endpoints/CardsEndpoints.cs` (+роут `/{cardId}/reclassify`, реальный batch)
- `Deal.Modules.Tenants/Application/AuditEvents.cs` (+`card_reclassified`) - `Deal.Modules.Tenants/Application/AuditEvents.cs` (+`card_reclassified`)
- `tests/Deal.Tests.Unit/FakeKanjStore.cs`, `AuditEventsTests.cs` - `tests/Deal.Tests.Unit/FakeKanjStore.cs`, `AuditEventsTests.cs`
- `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify) - `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify)
## Проверка ## Проверка
- `dotnet build Deal.sln -v q --nologo`**0/0**. - `dotnet build Deal.sln -v q --nologo`**0/0**.
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`**1203/1203 PASS** (+14: 13 новых - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`**1203/1203 PASS** (+14: 13 новых
CardReclassifierTests + 1 inline-кейс аудита). Покрыты: путь без ИИ (local fallback, порт не зовётся), CardReclassifierTests + 1 inline-кейс аудита). Покрыты: путь без ИИ (local fallback, порт не зовётся),
сбой классификатора → local, ИИ-доска (ContainerAccepts/страховка/обновление/сигналы ML), hits правил, сбой классификатора → local, ИИ-доска (ContainerAccepts/страховка/обновление/сигналы ML), hits правил,
спам → корзина + push 0.4, отсев фильтром, пустой inbox, фильтр по ids, пропуск без текста, одиночный спам → корзина + push 0.4, отсев фильтром, пустой inbox, фильтр по ids, пропуск без текста, одиночный
no-text, сохранение валидного старого контакта, busy-замок. no-text, сохранение валидного старого контакта, busy-замок.
- Docker/Postgres не поднимались; `dotnet build-server shutdown` не требовался (нет запущенных процессов). - Docker/Postgres не поднимались; `dotnet build-server shutdown` не требовался (нет запущенных процессов).
## Что осталось (вне этой задачи) ## Что осталось (вне этой задачи)
- SSE `leads_reclassified` и финальный тост «готово» прототипа не публикуются: переклассификация синхронная, - SSE `leads_reclassified` и финальный тост «готово» прототипа не публикуются: переклассификация синхронная,
фронт перезагружает доску по `started` (как и раньше, SSE-события boards_changed/leads_reclassified в core фронт перезагружает доску по `started` (как и раньше, SSE-события boards_changed/leads_reclassified в core
не публикуются — см. отчёт этапа 3, T15). не публикуются — см. отчёт этапа 3, T15).
- Пофайловый streaming-прогресс батча (прототип — фоновая задача с тостами) не делался: проход синхронный, - Пофайловый streaming-прогресс батча (прототип — фоновая задача с тостами) не делался: проход синхронный,
single-flight через `busy`. single-flight через `busy`.
@@ -1,81 +1,81 @@
# Task E report — закрытие двух остатков после этапа 12 (SDD, автономный заход) # Task E report — закрытие двух остатков после этапа 12 (SDD, автономный заход)
План этапа: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md`. План этапа: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md`.
Контракт (обновлён только раздел SSE/reclassify): `docs/architecture/2026-09-10-unified-api-contract.md`. Контракт (обновлён только раздел SSE/reclassify): `docs/architecture/2026-09-10-unified-api-contract.md`.
## 1. Убран дубляж и гонка `FreeTcpPort()` в тест-харнессе core ## 1. Убран дубляж и гонка `FreeTcpPort()` в тест-харнессе core
Размноженные приватные копии `FreeTcpPort()` (10 штук в `src/core/tests/Deal.Tests.Unit`) искали свободный Размноженные приватные копии `FreeTcpPort()` (10 штук в `src/core/tests/Deal.Tests.Unit`) искали свободный
порт связкой «биндинг `127.0.0.1:0` → немедленное освобождение». При параллельном прогоне ОС могла выдать порт связкой «биндинг `127.0.0.1:0` → немедленное освобождение». При параллельном прогоне ОС могла выдать
один и тот же освобождённый эфемерный порт двум тестам — второй бинд Kestrel/gRPC падал с один и тот же освобождённый эфемерный порт двум тестам — второй бинд Kestrel/gRPC падал с
`AddressInUseException` (`SocketException`). `AddressInUseException` (`SocketException`).
**Новый единый хелпер** `tests/Deal.Tests.Unit/TestPort.cs` (1 тип = 1 файл): **Новый единый хелпер** `tests/Deal.Tests.Unit/TestPort.cs` (1 тип = 1 файл):
- `TestPort.Allocate()` биндит порт 0, читает выданный ОС порт и освобождает слушатель; - `TestPort.Allocate()` биндит порт 0, читает выданный ОС порт и освобождает слушатель;
- выданные в процессе порты запоминаются в `ConcurrentDictionary` и **повторно не выдаются** — при коллизии - выданные в процессе порты запоминаются в `ConcurrentDictionary` и **повторно не выдаются** — при коллизии
биндинг порта 0 повторяется (до `MaxAttempts = 64`), поэтому порты не пересекаются между тестами; биндинг порта 0 повторяется (до `MaxAttempts = 64`), поэтому порты не пересекаются между тестами;
- при исчерпании попыток — понятное `InvalidOperationException`. - при исчерпании попыток — понятное `InvalidOperationException`.
Все 10 копий заменены на `TestPort.Allocate()`; приватные методы удалены, неиспользуемый Все 10 копий заменены на `TestPort.Allocate()`; приватные методы удалены, неиспользуемый
`using System.Net.Sockets;` убран (`using System.Net;` сохранён — он нужен для `IPAddress.Loopback`). `using System.Net.Sockets;` убран (`using System.Net;` сохранён — он нужен для `IPAddress.Loopback`).
Поведение тестов не менялось. Поведение тестов не менялось.
Затронуты: `AiGrpcTestHost.cs`, `MlGrpcTestHost.cs`, `TelegramGrpcTestHost.cs`, `TelegramIngressTestHost.cs`, Затронуты: `AiGrpcTestHost.cs`, `MlGrpcTestHost.cs`, `TelegramGrpcTestHost.cs`, `TelegramIngressTestHost.cs`,
`ServiceHealthProbeTests.cs`, `OperatorAuthHttpHost.cs`, `ForwardedHeadersHttpTests.cs`, `ServiceHealthProbeTests.cs`, `OperatorAuthHttpHost.cs`, `ForwardedHeadersHttpTests.cs`,
`JoinEndpointHttpTests.cs`, `OriginGuardHttpTests.cs`, `RateLimitHttpTests.cs`. `JoinEndpointHttpTests.cs`, `OriginGuardHttpTests.cs`, `RateLimitHttpTests.cs`.
## 2. SSE-событие о завершении переклассификации ## 2. SSE-событие о завершении переклассификации
### Бэкенд ### Бэкенд
`Deal.Api/Endpoints/CardsEndpoints.cs`: `Deal.Api/Endpoints/CardsEndpoints.cs`:
- новый тип события `cards_reclassified` (константа `ReclassifiedEventType`), публикуется из **обоих** - новый тип события `cards_reclassified` (константа `ReclassifiedEventType`), публикуется из **обоих**
эндпоинтов (`POST /api/cards/reclassify` и `POST /api/cards/{cardId}/reclassify`); эндпоинтов (`POST /api/cards/reclassify` и `POST /api/cards/{cardId}/reclassify`);
- публикация — после успешного прохода: `started && reclassified > 0`. Пустой inbox / всё пропущено доску не - публикация — после успешного прохода: `started && reclassified > 0`. Пустой inbox / всё пропущено доску не
меняют — событие не шлётся (нет лишней перезагрузки на фронте); меняют — событие не шлётся (нет лишней перезагрузки на фронте);
- payload минимальный, camelCase: `{ reclassified, moved }` — сколько обработано и перемещено; - payload минимальный, camelCase: `{ reclassified, moved }` — сколько обработано и перемещено;
- публикует `SseBroker` в канал тенанта сессии (`context.GetCurrentUser()!.TenantId`), без подписчиков — - публикует `SseBroker` в канал тенанта сессии (`context.GetCurrentUser()!.TenantId`), без подписчиков —
no-op (Ruling 5: публикации из Api). no-op (Ruling 5: публикации из Api).
### Фронт ### Фронт
- `src/frontend/src/api.js` — в `openEvents` добавлен `es.addEventListener('cards_reclassified', …)` - `src/frontend/src/api.js` — в `openEvents` добавлен `es.addEventListener('cards_reclassified', …)`
(ранее событие не регистрировалось и не доходило до store); (ранее событие не регистрировалось и не доходило до store);
- `src/frontend/src/store/lifecycle.js` — ветка обработки переименована с мёртвого `leads_reclassified` на - `src/frontend/src/store/lifecycle.js` — ветка обработки переименована с мёртвого `leads_reclassified` на
`cards_reclassified`: мягкий `reloadBoardData()` (контейнеры + карточки) и `refreshMlStatus()`; `cards_reclassified`: мягкий `reloadBoardData()` (контейнеры + карточки) и `refreshMlStatus()`;
поведение существующих событий не менялось. поведение существующих событий не менялось.
Текущее поведение сохранено: синхронный ответ по-прежнему тостит и перезагружает доску по `started`, а SSE Текущее поведение сохранено: синхронный ответ по-прежнему тостит и перезагружает доску по `started`, а SSE
дополнительно закрывает случай «доска в другой вкладке/фоновая переклассификация». Новых пользовательских дополнительно закрывает случай «доска в другой вкладке/фоновая переклассификация». Новых пользовательских
строк нет (событие без тоста) — словарь i18n не требуется. строк нет (событие без тоста) — словарь i18n не требуется.
## Файлы ## Файлы
Создано: Создано:
- `src/core/tests/Deal.Tests.Unit/TestPort.cs` - `src/core/tests/Deal.Tests.Unit/TestPort.cs`
Изменено: Изменено:
- `src/core/tests/Deal.Tests.Unit/` — 10 файлов (замена `FreeTcpPort()` на `TestPort.Allocate()`) - `src/core/tests/Deal.Tests.Unit/` — 10 файлов (замена `FreeTcpPort()` на `TestPort.Allocate()`)
- `src/core/Deal.Api/Endpoints/CardsEndpoints.cs` (+`using Deal.Api.Events;`, `ReclassifiedEventType`, - `src/core/Deal.Api/Endpoints/CardsEndpoints.cs` (+`using Deal.Api.Events;`, `ReclassifiedEventType`,
`PublishReclassified`, публикация в обоих reclassify-эндпоинтах) `PublishReclassified`, публикация в обоих reclassify-эндпоинтах)
- `src/frontend/src/api.js`, `src/frontend/src/store/lifecycle.js` - `src/frontend/src/api.js`, `src/frontend/src/store/lifecycle.js`
- `docs/architecture/2026-09-10-unified-api-contract.md` (только раздел SSE и reclassify) - `docs/architecture/2026-09-10-unified-api-contract.md` (только раздел SSE и reclassify)
## Проверка ## Проверка
- `dotnet build Deal.sln -v q --nologo`**0/0** (без предупреждений). - `dotnet build Deal.sln -v q --nologo`**0/0** (без предупреждений).
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`**1203/1203 PASS**. - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`**1203/1203 PASS**.
- `npm run build` — зелёно (`✓ built in 1.45s`, крупнейший чанк `index` 309.09 kB). - `npm run build` — зелёно (`✓ built in 1.45s`, крупнейший чанк `index` 309.09 kB).
- `npm run lint:i18n` — зелёно («кириллических пользовательских строк вне словарей не найдено»). - `npm run lint:i18n` — зелёно («кириллических пользовательских строк вне словарей не найдено»).
- `dotnet build-server shutdown` выполнен, запущенных процессов не осталось. - `dotnet build-server shutdown` выполнен, запущенных процессов не осталось.
## Что осталось (вне этой задачи) ## Что осталось (вне этой задачи)
- Двойная перезагрузка доски у инициатора batch-запроса: ответ эндпоинта (`started``reloadBoardData`) и - Двойная перезагрузка доски у инициатора batch-запроса: ответ эндпоинта (`started``reloadBoardData`) и
SSE-событие. Осознанно оставлено ради «не ломать текущее поведение» и работы при отвале SSE; при желании SSE-событие. Осознанно оставлено ради «не ломать текущее поведение» и работы при отвале SSE; при желании
можно убрать reload из `reclassifyInbox`, положившись только на событие. можно убрать reload из `reclassifyInbox`, положившись только на событие.
- Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный). - Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный).
- Мёртвые ветки `boards_changed`/`pipeline_stats` во фронте: core этих событий по-прежнему не публикует - Мёртвые ветки `boards_changed`/`pipeline_stats` во фронте: core этих событий по-прежнему не публикует
(унаследовано от этапа 3; вне задачи). (унаследовано от этапа 3; вне задачи).
@@ -1,95 +1,95 @@
# Task — Финальный отчёт: частичное обновление Telegram-ключей, миграция GlobalSettings, сквозная проверка # Task — Финальный отчёт: частичное обновление Telegram-ключей, миграция GlobalSettings, сквозная проверка
Дата: 2026-09-10. Область: `src/core` (+ контрактный док, отчёты в `.superpowers`). Фронт/сервисы/`deploy` Дата: 2026-09-10. Область: `src/core` (+ контрактный док, отчёты в `.superpowers`). Фронт/сервисы/`deploy`
не менялись (deploy только поднимался/гасился для проверки миграции). не менялись (deploy только поднимался/гасился для проверки миграции).
## 1. Частичное обновление ключей Telegram ## 1. Частичное обновление ключей Telegram
Ручка `PUT /api/operator/settings/telegram-keys` теперь принимает **частичное** тело `{apiId?, apiHash?}`: Ручка `PUT /api/operator/settings/telegram-keys` теперь принимает **частичное** тело `{apiId?, apiHash?}`:
| Вход | Поведение | | Вход | Поведение |
|---|---| |---|---|
| `{apiId, apiHash}` | полное обновление, как раньше | | `{apiId, apiHash}` | полное обновление, как раньше |
| `{apiId}` | обновляет `apiId`, `apiHash` берётся из текущих ключей | | `{apiId}` | обновляет `apiId`, `apiHash` берётся из текущих ключей |
| `{apiHash}` | обновляет `apiHash`, `apiId` берётся из текущих ключей | | `{apiHash}` | обновляет `apiHash`, `apiId` берётся из текущих ключей |
| `{}` (ни одного поля) | `400 {detail: "Укажите api_id и api_hash"}` | | `{}` (ни одного поля) | `400 {detail: "Укажите api_id и api_hash"}` |
| частичное, но ключей ещё нет | `400 {detail: "Ключи ещё не заданы — укажите и api_id, и api_hash"}` | | частичное, но ключей ещё нет | `400 {detail: "Ключи ещё не заданы — укажите и api_id, и api_hash"}` |
Правила: Правила:
- `null`/отсутствие поля = «не менялось» (берём текущее значение). - `null`/отсутствие поля = «не менялось» (берём текущее значение).
- Явное значение (в т.ч. пустая строка) валидируется прежними правилами: `apiId` — 59 цифр, - Явное значение (в т.ч. пустая строка) валидируется прежними правилами: `apiId` — 59 цифр,
`apiHash` — непустой, не маска (`…`), без префикса `enc:`. `apiHash` — непустой, не маска (`…`), без префикса `enc:`.
- Недостающее поле, которого ещё нет в хранилище, → `400` (нельзя «дополнить» отсутствующее значение). - Недостающее поле, которого ещё нет в хранилище, → `400` (нельзя «дополнить» отсутствующее значение).
- Ответ — прежняя маска-форма `{apiId, apiHash(маска), keysSet}`; аудит `telegram_keys_changed` - Ответ — прежняя маска-форма `{apiId, apiHash(маска), keysSet}`; аудит `telegram_keys_changed`
(`{apiId: <эффективный>, apiHashSet: true}` — секрет не пишется). (`{apiId: <эффективный>, apiHashSet: true}` — секрет не пишется).
Реализация — в `Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` (`PutTelegramKeysAsync`): читает текущий Реализация — в `Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` (`PutTelegramKeysAsync`): читает текущий
снимок через `TelegramKeysService.GetAsync`, сливает с переданными полями, валидирует и сохраняет снимок через `TelegramKeysService.GetAsync`, сливает с переданными полями, валидирует и сохраняет
`SaveAsync(effectiveApiId, effectiveApiHash)`. Строгая валидация самого сервиса не менялась `SaveAsync(effectiveApiId, effectiveApiHash)`. Строгая валидация самого сервиса не менялась
(defence in depth). (defence in depth).
Изменённые файлы: Изменённые файлы:
- `src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` — логика слияния, новый `400`-detail, XML-doc. - `src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` — логика слияния, новый `400`-detail, XML-doc.
- `src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs` — doc: поля опциональны. - `src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs` — doc: поля опциональны.
- `docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел PUT (примеры, семантика, ошибки). - `docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел PUT (примеры, семантика, ошибки).
- `src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs` — +4 теста: - `src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs` — +4 теста:
`PutTelegramKeys_OnlyApiId_KeepsExistingApiHash`, `PutTelegramKeys_OnlyApiHash_KeepsExistingApiId`, `PutTelegramKeys_OnlyApiId_KeepsExistingApiHash`, `PutTelegramKeys_OnlyApiHash_KeepsExistingApiId`,
`PutTelegramKeys_PartialWithoutExistingKeys_Returns400`, `PutTelegramKeys_NoFields_Returns400`. `PutTelegramKeys_PartialWithoutExistingKeys_Returns400`, `PutTelegramKeys_NoFields_Returns400`.
Прежние кейсы (пустой/маскированный `apiHash`, невалидный `apiId`, 401) сохранены. Прежние кейсы (пустой/маскированный `apiHash`, невалидный `apiId`, 401) сохранены.
## 2. Миграция `GlobalSettings` ## 2. Миграция `GlobalSettings`
- Postgres поднят: `docker compose -f deploy/compose.dev.yml up -d postgres` (контейнер `deal-postgres`, - Postgres поднят: `docker compose -f deploy/compose.dev.yml up -d postgres` (контейнер `deal-postgres`,
хост-порт `5433`). хост-порт `5433`).
- Применение: `dotnet ef database update --context DealDbContext --project Deal.Infrastructure - Применение: `dotnet ef database update --context DealDbContext --project Deal.Infrastructure
--startup-project Deal.Api --no-build` со строкой подключения --startup-project Deal.Api --no-build` со строкой подключения
`Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password` `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password`
(env `ConnectionStrings__DealPostgres`). (env `ConnectionStrings__DealPostgres`).
Вывод: `Applying migration '20260910194443_GlobalSettings'. Done.` Вывод: `Applying migration '20260910194443_GlobalSettings'. Done.`
> Отклонение от формулировки задания: `--startup-project Deal.Infrastructure` не работает — это > Отклонение от формулировки задания: `--startup-project Deal.Infrastructure` не работает — это
> class library без entry point (design-time factory в репозитории отсутствует); штатный startup — > class library без entry point (design-time factory в репозитории отсутствует); штатный startup —
> `Deal.Api` (так же в техдоке §13.2 и прошлых задачах). Env-переменная конфигурации — > `Deal.Api` (так же в техдоке §13.2 и прошлых задачах). Env-переменная конфигурации —
> `ConnectionStrings__DealPostgres` (ключ `ConnectionStrings:DealPostgres`), не `DEAL_PG_CONNECTION`. > `ConnectionStrings__DealPostgres` (ключ `ConnectionStrings:DealPostgres`), не `DEAL_PG_CONNECTION`.
**Проверка таблицы** (`docker exec deal-postgres psql -U deal -d deal -c "\d global_settings"`): **Проверка таблицы** (`docker exec deal-postgres psql -U deal -d deal -c "\d global_settings"`):
``` ```
Table "public.global_settings" Table "public.global_settings"
Column | Type | Nullable | Column | Type | Nullable |
-----------+--------------------------+----------+ -----------+--------------------------+----------+
Key | character varying(200) | not null | Key | character varying(200) | not null |
Value | text | not null | Value | text | not null |
UpdatedAt | timestamp with time zone | not null | UpdatedAt | timestamp with time zone | not null |
Indexes: Indexes:
"PK_global_settings" PRIMARY KEY, btree ("Key") "PK_global_settings" PRIMARY KEY, btree ("Key")
``` ```
В `public.__EFMigrationsHistory` последняя запись — `20260910194443_GlobalSettings`. В `public.__EFMigrationsHistory` последняя запись — `20260910194443_GlobalSettings`.
- Postgres погашен: `docker compose -f deploy/compose.dev.yml down` (удалены контейнер и сеть). - Postgres погашен: `docker compose -f deploy/compose.dev.yml down` (удалены контейнер и сеть).
## 3. Сквозная проверка (все 4 решения) ## 3. Сквозная проверка (все 4 решения)
| Решение | Build | Тесты | | Решение | Build | Тесты |
|---|---|---| |---|---|---|
| `src/core/Deal.sln` | 0 warnings / 0 errors | **1275 / 1275 PASS** (было 1271; +4) | | `src/core/Deal.sln` | 0 warnings / 0 errors | **1275 / 1275 PASS** (было 1271; +4) |
| `src/telegram-service/Deal.Telegram.sln` | 0 warnings / 0 errors | **125 / 125 PASS** | | `src/telegram-service/Deal.Telegram.sln` | 0 warnings / 0 errors | **125 / 125 PASS** |
| `src/ai-service/Deal.Ai.sln` | 0 warnings / 0 errors | **52 / 52 PASS** | | `src/ai-service/Deal.Ai.sln` | 0 warnings / 0 errors | **52 / 52 PASS** |
| `src/ml-service/Deal.Ml.sln` | 0 warnings / 0 errors | **38 / 38 PASS** | | `src/ml-service/Deal.Ml.sln` | 0 warnings / 0 errors | **38 / 38 PASS** |
Команды: `dotnet build <sln>` и `dotnet test <sln> --no-build` из каталогов решений. Во всех решениях Команды: `dotnet build <sln>` и `dotnet test <sln> --no-build` из каталогов решений. Во всех решениях
`TreatWarningsAsErrors=true` (`Directory.Build.props`), поэтому успешная сборка = 0 warnings. `TreatWarningsAsErrors=true` (`Directory.Build.props`), поэтому успешная сборка = 0 warnings.
- **Легаси-перенос.** Сборка/тесты всех решений зелёные — перенос в `archive/leadradar-legacy/` ничего - **Легаси-перенос.** Сборка/тесты всех решений зелёные — перенос в `archive/leadradar-legacy/` ничего
не сломал (проекты на него не ссылаются). не сломал (проекты на него не ссылаются).
- **Хвостов нет:** `docker ps -a` — контейнеров `deal-*` нет (postgres удалён), `dotnet build-server - **Хвостов нет:** `docker ps -a` — контейнеров `deal-*` нет (postgres удалён), `dotnet build-server
shutdown` выполнен, порты `5433/5080/5082/5101/5102/5103` без LISTENING. shutdown` выполнен, порты `5433/5080/5082/5101/5102/5103` без LISTENING.
## Замечания / на будущее ## Замечания / на будущее
- `docs/api/api-map.md` по-прежнему не описывает `GET/PUT /api/operator/settings/telegram-keys` - `docs/api/api-map.md` по-прежнему не описывает `GET/PUT /api/operator/settings/telegram-keys`
(из отчёта фронт-задачи) — контракт живёт в `docs/architecture/2026-09-10-operator-analytics-contract.md`. (из отчёта фронт-задачи) — контракт живёт в `docs/architecture/2026-09-10-operator-analytics-contract.md`.
Вне области этой задачи. Вне области этой задачи.
- Фронтенд-форма (`TelegramSection.vue`) шлёт оба поля всегда — частичный режим обратно совместим, - Фронтенд-форма (`TelegramSection.vue`) шлёт оба поля всегда — частичный режим обратно совместим,
UI-правок не требует (при желании можно отправлять только изменённые поля). UI-правок не требует (при желании можно отправлять только изменённые поля).
@@ -1,116 +1,116 @@
# Task — Telegram-ключи: фронтенд (вариант A, глобальные ключи оператора) # Task — Telegram-ключи: фронтенд (вариант A, глобальные ключи оператора)
Дата: 2026-09-10. Область: только `src/frontend` (+ этот отчёт и ledger). Без новых зависимостей. Дата: 2026-09-10. Область: только `src/frontend` (+ этот отчёт и ledger). Без новых зависимостей.
Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Тёмная/светлая темы не ломались Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Тёмная/светлая темы не ломались
(новые элементы — на существующих токенах). Процессы не запускались, `:5173` свободен. (новые элементы — на существующих токенах). Процессы не запускались, `:5173` свободен.
Источник формы ручек — контракт `docs/architecture/2026-09-10-operator-analytics-contract.md` Источник формы ручек — контракт `docs/architecture/2026-09-10-operator-analytics-contract.md`
(раздел «Операторские настройки: глобальные ключи Telegram») и фактический код (раздел «Операторские настройки: глобальные ключи Telegram») и фактический код
`src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, `TelegramEndpoints.cs`. `src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, `TelegramEndpoints.cs`.
--- ---
## 1. Оператор-консоль: новый раздел «Telegram» ## 1. Оператор-консоль: новый раздел «Telegram»
**Новые/изменённые файлы:** **Новые/изменённые файлы:**
- `src/frontend/src/views/operator/TelegramSection.vue` — новый раздел. - `src/frontend/src/views/operator/TelegramSection.vue` — новый раздел.
- `src/frontend/src/views/operator/OperatorConsole.vue` — раздел добавлен в `SECTIONS` - `src/frontend/src/views/operator/OperatorConsole.vue` — раздел добавлен в `SECTIONS`
(`id: 'telegram'`, иконка `key`) после «Состояния системы». (`id: 'telegram'`, иконка `key`) после «Состояния системы».
- `src/frontend/src/store/operator.js` — состояние `op.tgKeys` и действия `loadTelegramKeys()` / - `src/frontend/src/store/operator.js` — состояние `op.tgKeys` и действия `loadTelegramKeys()` /
`saveTelegramKeys(payload)`. `saveTelegramKeys(payload)`.
- `src/frontend/src/api.js` — в клиент добавлен `api.put` (метода не было; используется - `src/frontend/src/api.js` — в клиент добавлен `api.put` (метода не было; используется
исключительно новой ручкой, других вызовов PUT во фронте нет). исключительно новой ручкой, других вызовов PUT во фронте нет).
**Поведение:** **Поведение:**
- `GET /api/operator/settings/telegram-keys` при монтировании: бейдж «Ключи заданы / не заданы», - `GET /api/operator/settings/telegram-keys` при монтировании: бейдж «Ключи заданы / не заданы»,
показ открытого `apiId` и **маски** `apiHash`; если ключей нет — предупреждающий текст, что показ открытого `apiId` и **маски** `apiHash`; если ключей нет — предупреждающий текст, что
подключение аккаунтов у тенантов недоступно. подключение аккаунтов у тенантов недоступно.
- Форма `api_id` + `api_hash` (секрет — `type=password`, `autocomplete=new-password`), кнопка - Форма `api_id` + `api_hash` (секрет — `type=password`, `autocomplete=new-password`), кнопка
«Сохранить ключи» → `PUT` с `{apiId, apiHash}`. «Сохранить ключи» → `PUT` с `{apiId, apiHash}`.
- Клиентская валидация 1:1 контракту: `api_id` — строго 5–9 цифр, `api_hash` — непустой; ошибки - Клиентская валидация 1:1 контракту: `api_id` — строго 5–9 цифр, `api_hash` — непустой; ошибки
выводятся инлайн через `:error` у `TextInput`. выводятся инлайн через `:error` у `TextInput`.
- Серверные 400 показываются точным текстом бэка (тост): `localizeError` при статусе 400 отдаёт - Серверные 400 показываются точным текстом бэка (тост): `localizeError` при статусе 400 отдаёт
`detail` приоритетнее общей формулировки (проверено по `src/i18n/errors.js`). `detail` приоритетнее общей формулировки (проверено по `src/i18n/errors.js`).
- После успешного сохранения поле `api_hash` очищается (секрет на клиенте не удерживается), а - После успешного сохранения поле `api_hash` очищается (секрет на клиенте не удерживается), а
снимок `op.tgKeys` берётся из ответа PUT. снимок `op.tgKeys` берётся из ответа PUT.
- 401 на `/api/operator/*` обрабатывается существующим `addUnauthorizedHandler` (гасит - 401 на `/api/operator/*` обрабатывается существующим `addUnauthorizedHandler` (гасит
операторскую сессию и показывает тост). операторскую сессию и показывает тост).
Переиспользованы примитивы `SectionLayout`, `Card`, `Badge`, `Button`, `TextInput`, `Icon`; Переиспользованы примитивы `SectionLayout`, `Card`, `Badge`, `Button`, `TextInput`, `Icon`;
стиль — как у остальных разделов консоли. стиль — как у остальных разделов консоли.
## 2. Настройки тенанта: ключи Telegram убраны ## 2. Настройки тенанта: ключи Telegram убраны
**Файлы:** `src/frontend/src/components/settings/TelegramTab.vue`, **Файлы:** `src/frontend/src/components/settings/TelegramTab.vue`,
`src/frontend/src/store/settings.js`, `src/frontend/src/store/core.js`. `src/frontend/src/store/settings.js`, `src/frontend/src/store/core.js`.
- Из вкладки Telegram удалён весь блок «Ключи приложения (api_id / api_hash)» (поля ввода, - Из вкладки Telegram удалён весь блок «Ключи приложения (api_id / api_hash)» (поля ввода,
инструкция my.telegram.org, кнопка сохранения, строка про сохранённые ключи). инструкция my.telegram.org, кнопка сохранения, строка про сохранённые ключи).
- Осталось подключение аккаунта: статус/логин, QR/телефон/код/2FA, авто-мониторинг новых чатов. - Осталось подключение аккаунта: статус/логин, QR/телефон/код/2FA, авто-мониторинг новых чатов.
- Если `GET /api/tg/status` вернул `keysSet:false` — над блоком подключения показывается - Если `GET /api/tg/status` вернул `keysSet:false` — над блоком подключения показывается
предупреждение: «Ключи приложения Telegram не заданы / Подключение аккаунтов недоступно, пока предупреждение: «Ключи приложения Telegram не заданы / Подключение аккаунтов недоступно, пока
оператор не задаст глобальные ключи…» (без деталей реализации). Кнопки QR/телефон по-прежнему оператор не задаст глобальные ключи…» (без деталей реализации). Кнопки QR/телефон по-прежнему
отключены (`:disabled="!state.tgKeysSet"`), подпись рядом уточнена. отключены (`:disabled="!state.tgKeysSet"`), подпись рядом уточнена.
- Из стора удалено всё, что относилось к публичным `tgKeys`: обработка `has('tgKeys')` в - Из стора удалено всё, что относилось к публичным `tgKeys`: обработка `has('tgKeys')` в
`applySettings`, функция `saveSettings()` целиком (была нужна только ключам), поля `applySettings`, функция `saveSettings()` целиком (была нужна только ключам), поля
`state.apiId` / `state.apiHash` (и динамические `apiIdMask`/`apiHashSet`). Поле `state.apiId` / `state.apiHash` (и динамические `apiIdMask`/`apiHashSet`). Поле
`state.tgKeysSet` **оставлено** — оно приходит из `/api/tg/status` и управляет доступностью `state.tgKeysSet` **оставлено** — оно приходит из `/api/tg/status` и управляет доступностью
подключения. Импорт `refreshTgStatus` из `settings.js` убран за ненадобностью. подключения. Импорт `refreshTgStatus` из `settings.js` убран за ненадобностью.
## 3. i18n ## 3. i18n
- Добавлены ключи `operator.telegram*`, `operator.globalnye-klyuchi-telegram*`, `operator.klyuchi-*`, - Добавлены ключи `operator.telegram*`, `operator.globalnye-klyuchi-telegram*`, `operator.klyuchi-*`,
`operator.api-id-*`, `operator.api-hash*`, `operator.ukazhite-nepustoj-api-hash`, `operator.api-id-*`, `operator.api-hash*`, `operator.ukazhite-nepustoj-api-hash`,
`operator.sohranit-klyuchi`, `operator.klyuchi-telegram-sohraneny`, `operator.sohranit-klyuchi`, `operator.klyuchi-telegram-sohraneny`,
`operator.smena-api-id-trebuet-api-hash`. `operator.smena-api-id-trebuet-api-hash`.
- Добавлены ключи `settings.klyuchi-prilozheniya-ne-zadany`, - Добавлены ключи `settings.klyuchi-prilozheniya-ne-zadany`,
`settings.podklyuchenie-akkauntov-nedostupno-poka-operator`, `settings.podklyuchenie-akkauntov-nedostupno-poka-operator`,
`settings.dostupno-posle-nastrojki-klyuchej-operatorom`. `settings.dostupno-posle-nastrojki-klyuchej-operatorom`.
- Удалены «мёртвые» ключи формы ключей тенанта: `settings.klyuchi-prilozheniya-api-id-api-hash`, - Удалены «мёртвые» ключи формы ключей тенанта: `settings.klyuchi-prilozheniya-api-id-api-hash`,
`settings.lyuboj-svoj-klient-telegram-ne-tolko-dejl`, `settings.eto-ne-login-a-pasport-klienta-imenno`, `settings.lyuboj-svoj-klient-telegram-ne-tolko-dejl`, `settings.eto-ne-login-a-pasport-klienta-imenno`,
`settings.kak-poluchit-klyuchi-odin-raz-2-minuty`, `settings.1-otkrojte`, `settings.kak-poluchit-klyuchi-odin-raz-2-minuty`, `settings.1-otkrojte`,
`settings.i-vojdite-po-nomeru-telefona`, `settings.2-perejdite-v`, `settings.i-vojdite-po-nomeru-telefona`, `settings.2-perejdite-v`,
`settings.sozdajte-prilozhenie-nazvanie-lyuboe`, `settings.3-skopirujte`, `settings.sozdajte-prilozhenie-nazvanie-lyuboe`, `settings.3-skopirujte`,
`settings.v-polya-nizhe-i-nazhmite-sohranit-klyuchi`, `settings.chislovoj-api-id-iz-my-telegram-org`, `settings.v-polya-nizhe-i-nazhmite-sohranit-klyuchi`, `settings.chislovoj-api-id-iz-my-telegram-org`,
`settings.bukvenno-cifrovoj-api-hash`, `settings.sohranit-klyuchi`, `settings.bukvenno-cifrovoj-api-hash`, `settings.sohranit-klyuchi`,
`settings.klyuchi-telegram-sohraneny-api-id`, `settings.klyuchi-eshche-ne-zadany`, `settings.klyuchi-telegram-sohraneny-api-id`, `settings.klyuchi-eshche-ne-zadany`,
`settings.snachala-poluchite-i-sohranite-api-id-api`, `settings.novye-klyuchi-ne-vvedeny-maska-ne`, `settings.snachala-poluchite-i-sohranite-api-id-api`, `settings.novye-klyuchi-ne-vvedeny-maska-ne`,
`settings.klyuchi-telegram-sohraneny`. Ключ `settings.i` оставлен — используется в `AiTab`/`ScopeTab`. `settings.klyuchi-telegram-sohraneny`. Ключ `settings.i` оставлен — используется в `AiTab`/`ScopeTab`.
## 4. Аудит (заодно, по каталогу событий контракта) ## 4. Аудит (заодно, по каталогу событий контракта)
- В `AUDIT_EVENT_TYPES` (фильтр лент «Аудит»/«Аналитика» в консоли) добавлен - В `AUDIT_EVENT_TYPES` (фильтр лент «Аудит»/«Аналитика» в консоли) добавлен
`telegram_keys_changed` — событие, которое бэкенд пишет при `PUT` глобальных ключей. `telegram_keys_changed` — событие, которое бэкенд пишет при `PUT` глобальных ключей.
--- ---
## Проверка ## Проверка
- `npm run build` — ✓ (`vite build`, 111 модулей; `OperatorConsole` — 60.08 kB, основной чанк - `npm run build` — ✓ (`vite build`, 111 модулей; `OperatorConsole` — 60.08 kB, основной чанк
316.17 kB, предупреждений о размере нет). 316.17 kB, предупреждений о размере нет).
- `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет). - `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет).
- `netstat :5173` — совпадений нет, порт свободен; dev-сервер не поднимался. - `netstat :5173` — совпадений нет, порт свободен; dev-сервер не поднимался.
## Найденные расхождения / замечания ## Найденные расхождения / замечания
1. **`docs/api/api-map.md` не описывает новую ручку.** В §6 («Реализовано в Deal») таблица 1. **`docs/api/api-map.md` не описывает новую ручку.** В §6 («Реализовано в Deal») таблица
операторских ручек есть, но `GET/PUT /api/operator/settings/telegram-keys` в неё не добавлена операторских ручек есть, но `GET/PUT /api/operator/settings/telegram-keys` в неё не добавлена
(контракт живёт только в `docs/architecture/2026-09-10-operator-analytics-contract.md`). (контракт живёт только в `docs/architecture/2026-09-10-operator-analytics-contract.md`).
Док не правил — вне области фронтенда; кандидат на синхронизацию бэкенд-агентом. Док не правил — вне области фронтенда; кандидат на синхронизацию бэкенд-агентом.
2. **PUT требует оба поля.** По контракту `api_hash` всегда обязателен, поэтому сменить один 2. **PUT требует оба поля.** По контракту `api_hash` всегда обязателен, поэтому сменить один
`api_id`, не вводя hash заново, нельзя (секрет не возвращается). Это отражено подсказкой в `api_id`, не вводя hash заново, нельзя (секрет не возвращается). Это отражено подсказкой в
форме: «Смена api_id требует повторного ввода api_hash». Если у владельца сценарий «сменить форме: «Смена api_id требует повторного ввода api_hash». Если у владельца сценарий «сменить
только api_id» — потребуется доработка контракта/ручки. только api_id» — потребуется доработка контракта/ручки.
3. **`state.tgKeysSet` в тенант-сторе** остаётся единственным «telegram-ключевым» полем — оно 3. **`state.tgKeysSet` в тенант-сторе** остаётся единственным «telegram-ключевым» полем — оно
приходит из `/api/tg/status` (поле `keysSet`), а не из публичных настроек. Это осознанно: приходит из `/api/tg/status` (поле `keysSet`), а не из публичных настроек. Это осознанно:
вкладка использует его для блокировки подключения. вкладка использует его для блокировки подключения.
4. **Прокси-линтер `i18n-lint` и «Число с разделителями»** — не относится к задаче, поведение 4. **Прокси-линтер `i18n-lint` и «Число с разделителями»** — не относится к задаче, поведение
скрипта не менялось. скрипта не менялось.
## Что осталось (вне области этой задачи) ## Что осталось (вне области этой задачи)
- `docs/api/api-map.md` — добавить строку про `GET/PUT /api/operator/settings/telegram-keys`. - `docs/api/api-map.md` — добавить строку про `GET/PUT /api/operator/settings/telegram-keys`.
- Применение миграции `GlobalSettings` к dev-Postgres (отложено бэкенд-агентом). - Применение миграции `GlobalSettings` к dev-Postgres (отложено бэкенд-агентом).
- Живой прогон консоли против поднятого стека (в этой сессии внешние процессы не запускались). - Живой прогон консоли против поднятого стека (в этой сессии внешние процессы не запускались).
@@ -1,123 +1,123 @@
# Task — Telegram-ключи: вариант A (глобальные ключи оператора). Отчёт # Task — Telegram-ключи: вариант A (глобальные ключи оператора). Отчёт
> Дата: 2026-09-10. Решение владельца — **вариант A**: `api_id`/`api_hash` задаёт оператор > Дата: 2026-09-10. Решение владельца — **вариант A**: `api_id`/`api_hash` задаёт оператор
> глобально (ТЗ §4.1/§8.1), тенант только подключает аккаунт. Область: `src/core` + комментарии > глобально (ТЗ §4.1/§8.1), тенант только подключает аккаунт. Область: `src/core` + комментарии
> `src/contracts/telegram.proto` + контрактная документация. Фронт, `telegram-service`, `deploy`, > `src/contracts/telegram.proto` + контрактная документация. Фронт, `telegram-service`, `deploy`,
> `.superpowers` (кроме ledger) не трогались. > `.superpowers` (кроме ledger) не трогались.
## Что сделано ## Что сделано
### 1. Глобальное хранилище ключей (`public.global_settings`) ### 1. Глобальное хранилище ключей (`public.global_settings`)
- Модуль Settings: - Модуль Settings:
- `Application/IGlobalSettingsStore.cs` — порт KV-хранилища глобальных (системных) настроек - `Application/IGlobalSettingsStore.cs` — порт KV-хранилища глобальных (системных) настроек
(`GetAsync`/`SetAsync`, значения — JSON-строки). (`GetAsync`/`SetAsync`, значения — JSON-строки).
- `Application/GlobalSettingsKeys.cs` — каталог ключей; `TelegramKeys = "telegramKeys"`. - `Application/GlobalSettingsKeys.cs` — каталог ключей; `TelegramKeys = "telegramKeys"`.
- Инфраструктура: - Инфраструктура:
- `Persistence/Entities/GlobalSettingEntity.cs` (`Key`, `Value`, `UpdatedAt`). - `Persistence/Entities/GlobalSettingEntity.cs` (`Key`, `Value`, `UpdatedAt`).
- `Persistence/GlobalSettingConfiguration.cs` — таблица `global_settings`, схема `public`, PK `Key`, - `Persistence/GlobalSettingConfiguration.cs` — таблица `global_settings`, схема `public`, PK `Key`,
`Key varchar(200)`, `Value text`. `Key varchar(200)`, `Value text`.
- `Persistence/DealDbContext``DbSet<GlobalSettingEntity> GlobalSettings` + `ApplyConfiguration`. - `Persistence/DealDbContext``DbSet<GlobalSettingEntity> GlobalSettings` + `ApplyConfiguration`.
- `Persistence/Repositories/GlobalSettingsStore.cs` — EF-адаптер (upsert, `UpdatedAt = UTC-now`). - `Persistence/Repositories/GlobalSettingsStore.cs` — EF-адаптер (upsert, `UpdatedAt = UTC-now`).
- `ServiceCollectionExtensions.AddDealPersistence``IGlobalSettingsStore → GlobalSettingsStore` (scoped). - `ServiceCollectionExtensions.AddDealPersistence``IGlobalSettingsStore → GlobalSettingsStore` (scoped).
- Миграция: `Migrations/20260910194443_GlobalSettings.cs` (+ `.Designer.cs`, обновлён - Миграция: `Migrations/20260910194443_GlobalSettings.cs` (+ `.Designer.cs`, обновлён
`DealDbContextModelSnapshot.cs`) — `dotnet ef migrations add GlobalSettings --context DealDbContext`. `DealDbContextModelSnapshot.cs`) — `dotnet ef migrations add GlobalSettings --context DealDbContext`.
Секреты хранятся тем же механизмом, что и секреты настроек: `apiHash` — AES-256-GCM (`enc:`), Секреты хранятся тем же механизмом, что и секреты настроек: `apiHash` — AES-256-GCM (`enc:`),
через существующий `ISecretCipher`/`AesGcmSecretCipher`. через существующий `ISecretCipher`/`AesGcmSecretCipher`.
### 2. Операторские ручки ### 2. Операторские ручки
`Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, группа `/api/operator/settings` (тег `operator-settings`): `Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, группа `/api/operator/settings` (тег `operator-settings`):
| Метод / путь | Ответ | Коды | | Метод / путь | Ответ | Коды |
|---|---|---| |---|---|---|
| `GET /api/operator/settings/telegram-keys` | `{apiId, apiHash, keysSet}``apiId` открыт, `apiHash` — маска | 200, 401 | | `GET /api/operator/settings/telegram-keys` | `{apiId, apiHash, keysSet}``apiId` открыт, `apiHash` — маска | 200, 401 |
| `PUT /api/operator/settings/telegram-keys` `{apiId, apiHash}` | та же маска-форма после сохранения | 200, 400, 401 | | `PUT /api/operator/settings/telegram-keys` `{apiId, apiHash}` | та же маска-форма после сохранения | 200, 400, 401 |
- Валидация: `api_id` — ровно 5–9 ASCII-цифр; `api_hash` — непустой, без маски (`…`) и без префикса `enc:`. - Валидация: `api_id` — ровно 5–9 ASCII-цифр; `api_hash` — непустой, без маски (`…`) и без префикса `enc:`.
- Ошибки: `400 {detail}` («api_id должен состоять из 5–9 цифр» / «Укажите непустой api_hash»), - Ошибки: `400 {detail}` («api_id должен состоять из 5–9 цифр» / «Укажите непустой api_hash»),
пустое тело — 400; `401 {detail:"Требуется вход оператора"}`. пустое тело — 400; `401 {detail:"Требуется вход оператора"}`.
- Аудит: `telegram_keys_changed` (actor `operator`, `tenantId: null`, детали `{apiId, apiHashSet}` - Аудит: `telegram_keys_changed` (actor `operator`, `tenantId: null`, детали `{apiId, apiHashSet}`
без секрета) — константа в `AuditEvents`. без секрета) — константа в `AuditEvents`.
- Запрос `Endpoints/RequestModels/OperatorTelegramKeysRequest.cs`, DTO - Запрос `Endpoints/RequestModels/OperatorTelegramKeysRequest.cs`, DTO
`Telegram/TelegramKeysMaskedDto.cs`, маппинг в `Program.cs`. `Telegram/TelegramKeysMaskedDto.cs`, маппинг в `Program.cs`.
### 3. Ядро читает глобальные ключи ### 3. Ядро читает глобальные ключи
- `TelegramKeysService` переведён с `ISettingsStore` (тенант) на `IGlobalSettingsStore` + - `TelegramKeysService` переведён с `ISettingsStore` (тенант) на `IGlobalSettingsStore` +
`GlobalSettingsKeys.TelegramKeys`; добавлены `GetMaskedAsync` и `SaveAsync` (валидация + шифрование), `GlobalSettingsKeys.TelegramKeys`; добавлены `GetMaskedAsync` и `SaveAsync` (валидация + шифрование),
сохранён `GetAsync` (расшифрованный снимок для команд входа). сохранён `GetAsync` (расшифрованный снимок для команд входа).
- `TgStatusService` и `/api/tg/start-phone|start-qr` работают от глобальных ключей; при отсутствии — - `TgStatusService` и `/api/tg/start-phone|start-qr` работают от глобальных ключей; при отсутствии —
`400 {detail:"Ключи Telegram не заданы оператором"}` (без падения), а `GET /api/tg/status` отдаёт `400 {detail:"Ключи Telegram не заданы оператором"}` (без падения), а `GET /api/tg/status` отдаёт
`keysSet:false`. `keysSet:false`.
- `TelegramKeysValue` — внутренняя (БД) форма значения `{apiId, apiHash:"enc:…"}`. - `TelegramKeysValue` — внутренняя (БД) форма значения `{apiId, apiHash:"enc:…"}`.
### 4. Настройки тенанта ### 4. Настройки тенанта
`tgKeys` удалён из: `SettingsService.PublicForms` (`ToPublic`/`Mask`), `TgKeysPublicDto`, `tgKeys` удалён из: `SettingsService.PublicForms` (`ToPublic`/`Mask`), `TgKeysPublicDto`,
`TgKeysSetting`, `SettingKind.TgKeys`, `SettingsDefaults.TgKeys`, `SettingsKeys.TgKeys` + каталога `TgKeysSetting`, `SettingKind.TgKeys`, `SettingsDefaults.TgKeys`, `SettingsKeys.TgKeys` + каталога
`PublicKeys`, `SettingsService.PatchSecrets` (`ApplyTgKeysKey`), `SettingsService.ReadMerge` `PublicKeys`, `SettingsService.PatchSecrets` (`ApplyTgKeysKey`), `SettingsService.ReadMerge`
(`MergeTgKeys`), `SettingsService` (случай `switch`, константы `ApiId*`/`ApiHashMinLength`), (`MergeTgKeys`), `SettingsService` (случай `switch`, константы `ApiId*`/`ApiHashMinLength`),
`PublicSettingsDto.TgKeys`. Публичный каталог — 43 ключа (было 44). Данные тестовые — миграция `PublicSettingsDto.TgKeys`. Публичный каталог — 43 ключа (было 44). Данные тестовые — миграция
данных не делалась. Вкладка/статус Telegram у тенанта остаются (подключение аккаунта). данных не делалась. Вкладка/статус Telegram у тенанта остаются (подключение аккаунта).
### 5. Proto-комментарии ### 5. Proto-комментарии
`src/contracts/telegram.proto`: «настройка tgKeys тенанта» → «глобальные ключи, задаёт оператор» `src/contracts/telegram.proto`: «настройка tgKeys тенанта» → «глобальные ключи, задаёт оператор»
(`StartPhone`/`StartPhoneRequest`), ошибка без ключей → 400 «Ключи Telegram не заданы оператором». (`StartPhone`/`StartPhoneRequest`), ошибка без ключей → 400 «Ключи Telegram не заданы оператором».
Аналогично обновлены XML-doc `ITelegramGateway`. Аналогично обновлены XML-doc `ITelegramGateway`.
### 6. Тесты ### 6. Тесты
- `FakeGlobalSettingsStore` (in-memory `IGlobalSettingsStore`). - `FakeGlobalSettingsStore` (in-memory `IGlobalSettingsStore`).
- `TelegramKeysServiceTests` (14): шифрование `apiHash` (`enc:`, секрет не в БД), чтение - `TelegramKeysServiceTests` (14): шифрование `apiHash` (`enc:`, секрет не в БД), чтение
расшифрованного снимка, маскирование ответа (в т.ч. короткий секрет — «s…»), валидация api_id/hash, расшифрованного снимка, маскирование ответа (в т.ч. короткий секрет — «s…»), валидация api_id/hash,
битый JSON/шифротекст. битый JSON/шифротекст.
- `GlobalSettingsStoreTests` (3, EF InMemory): отсутствующий ключ → null, upsert, UTC-`UpdatedAt`. - `GlobalSettingsStoreTests` (3, EF InMemory): отсутствующий ключ → null, upsert, UTC-`UpdatedAt`.
- `OperatorSettingsEndpointsHttpTests` (6): 401 без сессии, пустая форма, сохранение+маска+аудит, - `OperatorSettingsEndpointsHttpTests` (6): 401 без сессии, пустая форма, сохранение+маска+аудит,
чтение после PUT, 400 по api_id/api_hash. чтение после PUT, 400 по api_id/api_hash.
- `OperatorAuthHttpHost`: регистрация `IGlobalSettingsStore`/`ISecretCipher`/`TelegramKeysService`, - `OperatorAuthHttpHost`: регистрация `IGlobalSettingsStore`/`ISecretCipher`/`TelegramKeysService`,
маппинг `MapOperatorSettingsEndpoints`, новый вход `RunWithGlobalSettingsAsync`. маппинг `MapOperatorSettingsEndpoints`, новый вход `RunWithGlobalSettingsAsync`.
- Обновлены `TgStatusServiceTests` (глобальные ключи), `SettingsCatalogTests` (43 ключа, убран - Обновлены `TgStatusServiceTests` (глобальные ключи), `SettingsCatalogTests` (43 ключа, убран
`Defaults_TgKeysAreEmpty`), `SettingsServiceTests` (убраны tgKeys-кейсы). `Defaults_TgKeysAreEmpty`), `SettingsServiceTests` (убраны tgKeys-кейсы).
### 7. Контракт для фронта ### 7. Контракт для фронта
`docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел «Операторские настройки: `docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел «Операторские настройки:
глобальные ключи Telegram» (обе формы и коды) + событие `telegram_keys_changed` в каталоге. глобальные ключи Telegram» (обе формы и коды) + событие `telegram_keys_changed` в каталоге.
`docs/api/api-map.md` §3.4/§4.6 — убран `tgKeys` из настроек тенанта и добавлена ссылка на операторский `docs/api/api-map.md` §3.4/§4.6 — убран `tgKeys` из настроек тенанта и добавлена ссылка на операторский
контракт. контракт.
## Формы ручек (для фронта) ## Формы ручек (для фронта)
```jsonc ```jsonc
// GET /api/operator/settings/telegram-keys → 200 // GET /api/operator/settings/telegram-keys → 200
{ "apiId": "1234567", "apiHash": "abcd…mnop", "keysSet": true } { "apiId": "1234567", "apiHash": "abcd…mnop", "keysSet": true }
// PUT /api/operator/settings/telegram-keys // PUT /api/operator/settings/telegram-keys
{ "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // apiId: 59 цифр, apiHash непустой { "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // apiId: 59 цифр, apiHash непустой
// → 200 (та же маска-форма); 400 {detail}; 401 {detail:"Требуется вход оператора"} // → 200 (та же маска-форма); 400 {detail}; 401 {detail:"Требуется вход оператора"}
``` ```
## Миграция ## Миграция
- Создана: `20260910194443_GlobalSettings` (`--context DealDbContext`, каталог `Migrations/`, - Создана: `20260910194443_GlobalSettings` (`--context DealDbContext`, каталог `Migrations/`,
как у существующих системных миграций). как у существующих системных миграций).
- **Не применена** к БД: dev-Postgres (`127.0.0.1:5433`) в этой сессии не поднят — `dotnet ef - **Не применена** к БД: dev-Postgres (`127.0.0.1:5433`) в этой сессии не поднят — `dotnet ef
database update` вернул «Failed to connect». Применить при поднятом Postgres: database update` вернул «Failed to connect». Применить при поднятом Postgres:
`dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api`. `dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api`.
## Итог проверки ## Итог проверки
- `dotnet build Deal.sln -v q --nologo` — **0/0**. - `dotnet build Deal.sln -v q --nologo` — **0/0**.
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — **1271/1271 PASS** (было 1245; +26). - `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — **1271/1271 PASS** (было 1245; +26).
- Docker/Postgres не запускались; серверные/фоновые процессы не оставлялись. - Docker/Postgres не запускались; серверные/фоновые процессы не оставлялись.
## Осталось / заметки ## Осталось / заметки
- Применить системную миграцию на живой БД (см. выше) — Postgres не был доступен. - Применить системную миграцию на живой БД (см. выше) — Postgres не был доступен.
- Фронтенд (отдельный агент): убрать `tgKeys` с вкладки настроек; UI оператора — по разделу - Фронтенд (отдельный агент): убрать `tgKeys` с вкладки настроек; UI оператора — по разделу
операторского контракта. операторского контракта.
- `telegram-service` не менялся: ключи по-прежнему приходят в теле gRPC-запросов (источник ядра — - `telegram-service` не менялся: ключи по-прежнему приходят в теле gRPC-запросов (источник ядра —
глобальное хранилище). глобальное хранилище).
@@ -1,130 +1,130 @@
# Task — Добивка по ТЗ: бэкенд core (пункты 1–5) # Task — Добивка по ТЗ: бэкенд core (пункты 1–5)
Дата: 2026-09-10. Область: только `src/core` (+ один контрактный док и этот ledger). Дата: 2026-09-10. Область: только `src/core` (+ один контрактный док и этот ledger).
Итог: `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений; core-тесты **1245/1245** Итог: `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений; core-тесты **1245/1245**
(было 1203, +42 новых). (было 1203, +42 новых).
Проверка качества: код-стайл 1 тип = 1 файл, XML-doc на public, явные `public` у членов интерфейсов, Проверка качества: код-стайл 1 тип = 1 файл, XML-doc на public, явные `public` у членов интерфейсов,
русские комментарии, именованные константы вместо магических чисел, `DateTimeOffset` UTC, camelCase JSON, русские комментарии, именованные константы вместо магических чисел, `DateTimeOffset` UTC, camelCase JSON,
`TreatWarningsAsErrors` (сборка с `-warnaserror` зелёная), порт/адаптер (интерфейс в модуле, EF-адаптер `TreatWarningsAsErrors` (сборка с `-warnaserror` зелёная), порт/адаптер (интерфейс в модуле, EF-адаптер
в Infrastructure). в Infrastructure).
--- ---
## 1. ML-проверка на канале/сообщении (§8 ML, E11) ## 1. ML-проверка на канале/сообщении (§8 ML, E11)
Было: `POST /api/ml/candidates` возвращал `{items: []}`, `POST /api/ml/apply` — всегда 404. Было: `POST /api/ml/candidates` возвращал `{items: []}`, `POST /api/ml/apply` — всегда 404.
Стало: рабочий сервис ручной проверки/разметки. Стало: рабочий сервис ручной проверки/разметки.
- **Новый сервис** `Deal.Modules.Pipeline/Application/MlReviewService.cs`. - **Новый сервис** `Deal.Modules.Pipeline/Application/MlReviewService.cs`.
- `CandidatesAsync(dialogId, limit, ct)`: объединяет реальные сообщения из **очереди** (`IPipelineStore.ListAsync`), - `CandidatesAsync(dialogId, limit, ct)`: объединяет реальные сообщения из **очереди** (`IPipelineStore.ListAsync`),
**отсева** (`ListPageAsync`) и **карточек** (`ICardStore.ListCardsAsync`) по ключу (dialogId, msgId); **отсева** (`ListPageAsync`) и **карточек** (`ICardStore.ListCardsAsync`) по ключу (dialogId, msgId);
приоритет вердикта: card > rejected > queued. `dialogId` пустой — выборка по всем источникам; приоритет вердикта: card > rejected > queued. `dialogId` пустой — выборка по всем источникам;
`limit` клампится 1..60 (как прототип). Каждый кандидат несёт исходный текст (≤600), время, `lead`, `limit` клампится 1..60 (как прототип). Каждый кандидат несёт исходный текст (≤600), время, `lead`,
текущий `verdict` (+ `col`/`stage`/`reason`) и мнение ML (`IMlClient.PredictAsync`, сбой → `pred:null`). текущий `verdict` (+ `col`/`stage`/`reason`) и мнение ML (`IMlClient.PredictAsync`, сбой → `pred:null`).
- `ApplyAsync(dialogId, msgId, action, ct)`: `skip` (без обучения) / `spam` / `board:<id>`. - `ApplyAsync(dialogId, msgId, action, ct)`: `skip` (без обучения) / `spam` / `board:<id>`.
Обучение и переносы — **через существующие сервисы без дублей**: Обучение и переносы — **через существующие сервисы без дублей**:
`CardsService.TrashCardAsync(teach:true)` и `CardsService.MoveDashboardCardAsync` (они сами шлют `CardsService.TrashCardAsync(teach:true)` и `CardsService.MoveDashboardCardAsync` (они сами шлют
обучающий сигнал), `PipelineProcessingService.RejectAsync` + снятие строки очереди, `IMlClient.PushAsync` обучающий сигнал), `PipelineProcessingService.RejectAsync` + снятие строки очереди, `IMlClient.PushAsync`
только там, где карточки нет. Неизвестная доска/действие → 400; сообщения нет ни в одном источнике → 404. только там, где карточки нет. Неизвестная доска/действие → 400; сообщения нет ни в одном источнике → 404.
- **Новый порт-метод** `ICardStore.GetCardBySourceAsync(dialogId, msgId, ct)` (+ EF-адаптер `KanbanStore.Cards.cs`, - **Новый порт-метод** `ICardStore.GetCardBySourceAsync(dialogId, msgId, ct)` (+ EF-адаптер `KanbanStore.Cards.cs`,
+ `FakeKanjStore`): поиск карточки по исходному сообщению (для `lead`/переносов). + `FakeKanjStore`): поиск карточки по исходному сообщению (для `lead`/переносов).
- **Эндпоинты** `Deal.Api/Endpoints/MlEndpoints.cs`: `candidates`/`apply` переведены на `MlReviewService`. - **Эндпоинты** `Deal.Api/Endpoints/MlEndpoints.cs`: `candidates`/`apply` переведены на `MlReviewService`.
- **Новые DTO**: `MlCandidateDto`, `MlCandidatePredictionDto`, `MlApplyResult` - **Новые DTO**: `MlCandidateDto`, `MlCandidatePredictionDto`, `MlApplyResult`
(`Deal.Modules.Pipeline/Application/Models`). (`Deal.Modules.Pipeline/Application/Models`).
- **DI**: `MlReviewService` зарегистрирован scoped в `PipelineModuleRegistrar`. - **DI**: `MlReviewService` зарегистрирован scoped в `PipelineModuleRegistrar`.
- **Тесты**: `MlReviewServiceTests` — 11 (объединение источников, фильтр по каналу, кламп, skip/spam - **Тесты**: `MlReviewServiceTests` — 11 (объединение источников, фильтр по каналу, кламп, skip/spam
с картой и из очереди, board-перенос, неизвестная доска/действие, 404). с картой и из очереди, board-перенос, неизвестная доска/действие, 404).
### Контракт (frontend) — см. `docs/architecture/2026-09-10-unified-api-contract.md`, раздел ML ### Контракт (frontend) — см. `docs/architecture/2026-09-10-unified-api-contract.md`, раздел ML
`POST /api/ml/candidates` `{dialogId, limit}` `POST /api/ml/candidates` `{dialogId, limit}`
```json ```json
{ "items": [ { "id": 12345, "dialogId": "d_...", "text": "...", "time": 1757500000000, { "items": [ { "id": 12345, "dialogId": "d_...", "text": "...", "time": 1757500000000,
"lead": true, "verdict": "card", "col": "b_...", "stage": null, "reason": null, "lead": true, "verdict": "card", "col": "b_...", "stage": null, "reason": null,
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } } ] } "pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } } ] }
``` ```
`id` — id исходного сообщения (он же `msgId` для apply). `verdict`: `card`|`rejected`|`queued`. `id` — id исходного сообщения (он же `msgId` для apply). `verdict`: `card`|`rejected`|`queued`.
`POST /api/ml/apply` `{dialogId, msgId, action}``{ok, learned, moved, leadId}` `POST /api/ml/apply` `{dialogId, msgId, action}``{ok, learned, moved, leadId}`
(`action`: `skip`|`spam`|`board:<id>`; `moved`: `trash`|id колонки|null). 404/400 — как раньше по форме (`action`: `skip`|`spam`|`board:<id>`; `moved`: `trash`|id колонки|null). 404/400 — как раньше по форме
`{detail}`. `{detail}`.
> `MLPanel.vue` менять не требуется: он уже читает `items[].{id,dialogId,text,lead,pred}` и шлёт > `MLPanel.vue` менять не требуется: он уже читает `items[].{id,dialogId,text,lead,pred}` и шлёт
> `{dialogId,msgId:item.id,action}`, а `applyOne` использует `r.moved` — контракт совместим 1:1 с прототипом. > `{dialogId,msgId:item.id,action}`, а `applyOne` использует `r.moved` — контракт совместим 1:1 с прототипом.
## 2. Глобальные исключения — стоп-уровень до ML/ИИ (§5.14/§8) ## 2. Глобальные исключения — стоп-уровень до ML/ИИ (§5.14/§8)
- **Настройки** (каталог/дефолты/PATCH/GET): `excludeKeywords` (List), `excludeLocations` (List), - **Настройки** (каталог/дефолты/PATCH/GET): `excludeKeywords` (List), `excludeLocations` (List),
`excludeTypes` (List: `vacancy`/`freelance`/`announcement`), `excludeBudgetFrom`/`excludeBudgetTo` (Int; `excludeTypes` (List: `vacancy`/`freelance`/`announcement`), `excludeBudgetFrom`/`excludeBudgetTo` (Int;
0 = не задано). Все опциональные; пустые по умолчанию. Затронуты `SettingsKeys`, `SettingsDefaults`, 0 = не задано). Все опциональные; пустые по умолчанию. Затронуты `SettingsKeys`, `SettingsDefaults`,
`PublicSettingsDto`, `SettingsService.BuildSnapshot`. `PublicSettingsDto`, `SettingsService.BuildSnapshot`.
- **Чистое ядро** `Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs` (+ `GlobalExcludeSettings`, - **Чистое ядро** `Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs` (+ `GlobalExcludeSettings`,
`GlobalExclusionResult`): порядок — слова/технологии → локация/язык → тип (синонимы `TypeAliases`) → `GlobalExclusionResult`): порядок — слова/технологии → локация/язык → тип (синонимы `TypeAliases`) →
бюджет (числовые суммы `AmountParser`, без конвертации). Возвращает код правила, причину и терм. бюджет (числовые суммы `AmountParser`, без конвертации). Возвращает код правила, причину и терм.
- **Применение**: в `PipelineWorkerService.PumpNewPassAsync` сразу после этапа-1 правил и **до дедупа/ML/ИИ** - **Применение**: в `PipelineWorkerService.PumpNewPassAsync` сразу после этапа-1 правил и **до дедупа/ML/ИИ**
(токены не тратятся); отсев пишется `source=stop` с новыми этапами `exclude_kw`/`exclude_location`/ (токены не тратятся); отсев пишется `source=stop` с новыми этапами `exclude_kw`/`exclude_location`/
`exclude_type`/`exclude_budget` (подписи добавлены в `PipelineRejectConstants.StageLabels`). `exclude_type`/`exclude_budget` (подписи добавлены в `PipelineRejectConstants.StageLabels`).
- **Per-column exclude не тронут** (`ColumnExclusions` — вето размещения в колонку, как было). - **Per-column exclude не тронут** (`ColumnExclusions` — вето размещения в колонку, как было).
- **Тесты**: `GlobalExclusionRulesTests` (8), 2 интеграционных в `PipelineWorkerServiceTests` - **Тесты**: `GlobalExclusionRulesTests` (8), 2 интеграционных в `PipelineWorkerServiceTests`
(слово-исключение и бюджет — отсев до ML, `PredictCalls==0`), 2 в `SettingsServiceTests` (слово-исключение и бюджет — отсев до ML, `PredictCalls==0`), 2 в `SettingsServiceTests`
(round-trip PATCH/GET, дефолты), каталог `SettingsCatalogTests` обновлён (44 публичных ключа). (round-trip PATCH/GET, дефолты), каталог `SettingsCatalogTests` обновлён (44 публичных ключа).
## 3. Группы фильтров колонки (§6.3) ## 3. Группы фильтров колонки (§6.3)
- `ContainerRulesDto` расширен **в конец с дефолтами**: `levels`, `locations`, `types` (`IReadOnlyList<string>?`) - `ContainerRulesDto` расширен **в конец с дефолтами**: `levels`, `locations`, `types` (`IReadOnlyList<string>?`)
и `prices` (`BudgetRangeDto?`) — старые позиционные вызовы и сохранённый `RulesJson` обратно совместимы. и `prices` (`BudgetRangeDto?`) — старые позиционные вызовы и сохранённый `RulesJson` обратно совместимы.
- `IContainerRules` дополнен `Levels/Locations/Types/Prices`. - `IContainerRules` дополнен `Levels/Locations/Types/Prices`.
- Движок: `ColumnMatcher` (группы в `MatchText`/`ScoreText`/`HasActiveRules`; `levels` — через - Движок: `ColumnMatcher` (группы в `MatchText`/`ScoreText`/`HasActiveRules`; `levels` — через
`GradeAliases`, `types` — через новый `TypeAliases`, `prices` — диапазон как `budget`), `MatchHitBuilder` `GradeAliases`, `types` — через новый `TypeAliases`, `prices` — диапазон как `budget`), `MatchHitBuilder`
(новые метки `Уровень`/`Локация`/`Тип`/`Цена`), `RulesDescriber` (описание групп), `ContainersEndpoints.NormalizeWireRules`. (новые метки `Уровень`/`Локация`/`Тип`/`Цена`), `RulesDescriber` (описание групп), `ContainersEndpoints.NormalizeWireRules`.
- **Тесты**: `ColumnRulesNewGroupsTests` — 9 (матчинг, алиасы, диапазон цены, has-active, `matchHits`, - **Тесты**: `ColumnRulesNewGroupsTests` — 9 (матчинг, алиасы, диапазон цены, has-active, `matchHits`,
`describe`, camelCase-сериализация и обратная совместимость старого JSON). Существующие тесты правил зелёные. `describe`, camelCase-сериализация и обратная совместимость старого JSON). Существующие тесты правил зелёные.
## 4. Глубины очередей в `/api/operator/health` (§10.2) ## 4. Глубины очередей в `/api/operator/health` (§10.2)
- **Новый общий сборщик** `Deal.Api/Observability/RuntimeDepthsCollector` (+ `RuntimeDepthsDto`): обходит реестр - **Новый общий сборщик** `Deal.Api/Observability/RuntimeDepthsCollector` (+ `RuntimeDepthsDto`): обходит реестр
тенантов (scope на тенант) и считает существующими сервисами/портами — `PipelineProcessingService.QueueCountsAsync` тенантов (scope на тенант) и считает существующими сервисами/портами — `PipelineProcessingService.QueueCountsAsync`
(очередь), `IMlLearningStore.CountOutboxAsync` (ML-outbox), `public.sessions`+`operator_sessions` (сессии). (очередь), `IMlLearningStore.CountOutboxAsync` (ML-outbox), `public.sessions`+`operator_sessions` (сессии).
SQL не дублируется. SQL не дублируется.
- `DealMetricsCollector` переведён на этот сборщик (поведение метрик сохранено), регистрация singleton в `Program.cs`. - `DealMetricsCollector` переведён на этот сборщик (поведение метрик сохранено), регистрация singleton в `Program.cs`.
- `GET /api/operator/health` теперь отдаёт `queues:{pipeline, mlOutbox}` и `sessions:{active}` (сбор — параллельно - `GET /api/operator/health` теперь отдаёт `queues:{pipeline, mlOutbox}` и `sessions:{active}` (сбор — параллельно
с пробой БД/сервисов; сбои секций мягко дают 0, ручка всегда 200). с пробой БД/сервисов; сбои секций мягко дают 0, ручка всегда 200).
- **Тесты**: `RuntimeDepthsCollectorTests` — 2 (агрегат по двум тенантам; пустой реестр), - **Тесты**: `RuntimeDepthsCollectorTests` — 2 (агрегат по двум тенантам; пустой реестр),
`OperatorHealthEndpointsHttpTests` дополнен проверкой новых полей. `OperatorHealthEndpointsHttpTests` дополнен проверкой новых полей.
## 5. Детектор подозрительной активности (§10.5) ## 5. Детектор подозрительной активности (§10.5)
- **Новый сервис** `Deal.Modules.Tenants/Application/SuspiciousActivityService` (+ `SuspiciousFindingDto`, - **Новый сервис** `Deal.Modules.Tenants/Application/SuspiciousActivityService` (+ `SuspiciousFindingDto`,
`SuspiciousActivityDto`): анализ аудита за окно (по умолчанию 24 ч, ≤500 записей) по правилам с `SuspiciousActivityDto`): анализ аудита за окно (по умолчанию 24 ч, ≤500 записей) по правилам с
**именованными порогами**: `failed_logins_per_ip` (10), `failed_logins_per_login` (5), **именованными порогами**: `failed_logins_per_ip` (10), `failed_logins_per_login` (5),
`many_ips_per_actor` (5), `auth_failures_per_tenant` (20; косвенно серия 401/429). Уровень `high` при 2× порога. `many_ips_per_actor` (5), `auth_failures_per_tenant` (20; косвенно серия 401/429). Уровень `high` при 2× порога.
Зарегистрирован в `AddTenantsModule`. Зарегистрирован в `AddTenantsModule`.
- **Операторский эндпоинт** `GET /api/operator/analytics/suspicious?from=&to=``OperatorAnalyticsEndpoints`): - **Операторский эндпоинт** `GET /api/operator/analytics/suspicious?from=&to=``OperatorAnalyticsEndpoints`):
`{from, to, scanned, truncated, items:[{kind, severity, subject, count, detail}]}`, только под операторской сессией. `{from, to, scanned, truncated, items:[{kind, severity, subject, count, detail}]}`, только под операторской сессией.
- **Тесты**: `SuspiciousActivityServiceTests` — 7 (пусто, пороги IP/логина, high-уровень, много IP, тенант, - **Тесты**: `SuspiciousActivityServiceTests` — 7 (пусто, пороги IP/логина, high-уровень, много IP, тенант,
окно), HTTP — 401 во всех ручках + позитивный `Suspicious_ReturnsFindingsForFailedLoginBurst`. окно), HTTP — 401 во всех ручках + позитивный `Suspicious_ReturnsFindingsForFailedLoginBurst`.
--- ---
## Контрактные изменения (сводка для фронта) ## Контрактные изменения (сводка для фронта)
| Ручка/поле | Изменение | | Ручка/поле | Изменение |
|---|---| |---|---|
| `POST /api/ml/candidates` | `items` теперь реальные кандидаты: `{id,dialogId,text,time,lead,verdict,col,stage,reason,pred}` (было `[]`) | | `POST /api/ml/candidates` | `items` теперь реальные кандидаты: `{id,dialogId,text,time,lead,verdict,col,stage,reason,pred}` (было `[]`) |
| `POST /api/ml/apply` | Работает: `{ok,learned,moved,leadId}`; 404 — только если сообщения нет; 400 — неизвестная доска/действие | | `POST /api/ml/apply` | Работает: `{ok,learned,moved,leadId}`; 404 — только если сообщения нет; 400 — неизвестная доска/действие |
| `GET /api/operator/health` | + `queues:{pipeline,mlOutbox}`, `sessions:{active}` | | `GET /api/operator/health` | + `queues:{pipeline,mlOutbox}`, `sessions:{active}` |
| `GET /api/operator/analytics/suspicious` | Новый эндпоинт (находки по логам безопасности) | | `GET /api/operator/analytics/suspicious` | Новый эндпоинт (находки по логам безопасности) |
| `GET/PATCH /api/settings` | + `excludeKeywords`, `excludeLocations`, `excludeTypes`, `excludeBudgetFrom`, `excludeBudgetTo` | | `GET/PATCH /api/settings` | + `excludeKeywords`, `excludeLocations`, `excludeTypes`, `excludeBudgetFrom`, `excludeBudgetTo` |
| `rules` контейнера (GET/POST/PATCH) | + группы `levels`, `locations`, `types`, `prices` (старые поля и JSON совместимы) | | `rules` контейнера (GET/POST/PATCH) | + группы `levels`, `locations`, `types`, `prices` (старые поля и JSON совместимы) |
| `matchHits` | + метки `Уровень`, `Локация`, `Тип`, `Цена` | | `matchHits` | + метки `Уровень`, `Локация`, `Тип`, `Цена` |
`MLPanel.vue` совместим без правок; для настроек исключений и новых групп колонки фронту нужно добавить UI. `MLPanel.vue` совместим без правок; для настроек исключений и новых групп колонки фронту нужно добавить UI.
## Что осталось (вне области этой задачи, `src/core`) ## Что осталось (вне области этой задачи, `src/core`)
- Фронт: UI глобальных исключений (§5.14), групп колонки `levels/locations/types/prices` (§6.3), - Фронт: UI глобальных исключений (§5.14), групп колонки `levels/locations/types/prices` (§6.3),
«открыть исходник» на карточке (§6.6), переключатель языка/i18n (§11.12). «открыть исходник» на карточке (§6.6), переключатель языка/i18n (§11.12).
- §4.1/§8.1: `api_id/api_hash` — глобальная операторская настройка (сейчас ключи тенанта `tgKeys`). - §4.1/§8.1: `api_id/api_hash` — глобальная операторская настройка (сейчас ключи тенанта `tgKeys`).
- §11.6 Cloudflare, живые интеграции (Telegram/LLM), k8s — вне кода ядра. - §11.6 Cloudflare, живые интеграции (Telegram/LLM), k8s — вне кода ядра.
- Миграции БД не добавлялись: новые данные хранятся в существующих KV-настройках и JSON-полях, схема не менялась. - Миграции БД не добавлялись: новые данные хранятся в существующих KV-настройках и JSON-полях, схема не менялась.
@@ -1,99 +1,99 @@
# Task — Добивка по ТЗ: фронтенд (UI-части) # Task — Добивка по ТЗ: фронтенд (UI-части)
Дата: 2026-09-10. Область: только `src/frontend` (+ этот ledger/отчёт). Без новых зависимостей. Дата: 2026-09-10. Область: только `src/frontend` (+ этот ledger/отчёт). Без новых зависимостей.
Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Светлая и тёмная темы сохранены. Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Светлая и тёмная темы сохранены.
Бэкенд-контракты закрыты ранее (`task-tz-backend-report.md`); фронт сверен с фактическими формами Бэкенд-контракты закрыты ранее (`task-tz-backend-report.md`); фронт сверен с фактическими формами
`src/core/Deal.Api/Endpoints/*` и `PublicSettingsDto`/`ContainerRulesDto`, ничего не выдумано. `src/core/Deal.Api/Endpoints/*` и `PublicSettingsDto`/`ContainerRulesDto`, ничего не выдумано.
--- ---
## 1. «Открыть исходник» как быстрое действие карточки (§6.6) ## 1. «Открыть исходник» как быстрое действие карточки (§6.6)
Файл: `src/frontend/src/components/card/Card.vue`. Файл: `src/frontend/src/components/card/Card.vue`.
- Добавлена быстрая кнопка-ссылка в футере дашбордной карточки (рядом с комментарием/корзиной/переносом, - Добавлена быстрая кнопка-ссылка в футере дашбордной карточки (рядом с комментарием/корзиной/переносом,
левее комментария), только в режиме `dashboard` (не в «Выбранных»). левее комментария), только в режиме `dashboard` (не в «Выбранных»).
- Ссылка строится существующим хелпером `tgSourceUrl(card)` (`channel.handle``t.me/<handle>/<msg>`, - Ссылка строится существующим хелпером `tgSourceUrl(card)` (`channel.handle``t.me/<handle>/<msg>`,
иначе приватный `t.me/c/<peer>/<msg>` по `sourceDialogId`+`sourceMsgId`). Если данных для ссылки нет — иначе приватный `t.me/c/<peer>/<msg>` по `sourceDialogId`+`sourceMsgId`). Если данных для ссылки нет —
действие недоступно (кнопка не рендерится). Клик не открывает карточку (`@click.stop`), цель `_blank`. действие недоступно (кнопка не рендерится). Клик не открывает карточку (`@click.stop`), цель `_blank`.
- Строка подписи переиспользует существующий ключ `drawer.otkryt-ishodnoe-soobshchenie-v-telegram`; иконка - Строка подписи переиспользует существующий ключ `drawer.otkryt-ishodnoe-soobshchenie-v-telegram`; иконка
`external` — из общего `Icon.vue`. `external` — из общего `Icon.vue`.
## 2. Глобальные исключения в настройках (§5.14/§8) ## 2. Глобальные исключения в настройках (§5.14/§8)
Файлы: `src/frontend/src/store/core.js`, `src/frontend/src/store/settings.js`, Файлы: `src/frontend/src/store/core.js`, `src/frontend/src/store/settings.js`,
`src/frontend/src/components/settings/StopTab.vue` (вкладка «Фильтры входящих»). `src/frontend/src/components/settings/StopTab.vue` (вкладка «Фильтры входящих»).
- В `state` добавлены `excludeKeywords`, `excludeLocations`, `excludeTypes` (массивы), - В `state` добавлены `excludeKeywords`, `excludeLocations`, `excludeTypes` (массивы),
`excludeBudgetFrom`, `excludeBudgetTo` (int, 0 = не задано); `applySettings` применяет их с сервера. `excludeBudgetFrom`, `excludeBudgetTo` (int, 0 = не задано); `applySettings` применяет их с сервера.
- Действия store: `addExcludeTerm`/`removeExcludeTerm` (нормализация trim+lower, как у стоп-фраз), - Действия store: `addExcludeTerm`/`removeExcludeTerm` (нормализация trim+lower, как у стоп-фраз),
`toggleExcludeType`, `persistExclusions` — одним PATCH `/api/settings` со всеми пятью ключами. `toggleExcludeType`, `persistExclusions` — одним PATCH `/api/settings` со всеми пятью ключами.
- UI-блок «Глобальные исключения»: тег-списки для ключевых слов и локаций (`TagInput`, `danger`), - UI-блок «Глобальные исключения»: тег-списки для ключевых слов и локаций (`TagInput`, `danger`),
чипы типов (вакансия/фриланс/объявление — теги `vacancy`/`freelance`/`announcement`), диапазон «от/до» чипы типов (вакансия/фриланс/объявление — теги `vacancy`/`freelance`/`announcement`), диапазон «от/до»
для бюджета и кнопка «Сохранить». Все поля опциональны; пустые = исключение не срабатывает. для бюджета и кнопка «Сохранить». Все поля опциональны; пустые = исключение не срабатывает.
- Строки — в словаре (`settings.globalnye-isklyucheniya*`, `settings.isklyucheniya-*`). - Строки — в словаре (`settings.globalnye-isklyucheniya*`, `settings.isklyucheniya-*`).
## 3. Новые группы фильтров колонки (§6.3) ## 3. Новые группы фильтров колонки (§6.3)
Файл: `src/frontend/src/components/BoardRulesDialog.vue`. Файл: `src/frontend/src/components/BoardRulesDialog.vue`.
- `form`/`load()`/`buildRules()`/`clearRules()` расширены группами `levels`, `locations`, `types`, `prices`. - `form`/`load()`/`buildRules()`/`clearRules()` расширены группами `levels`, `locations`, `types`, `prices`.
- UI: `levels` («Уровень») и `locations` («Локация / язык») — группы-теги рядом с keywords/stack/grade; - UI: `levels` («Уровень») и `locations` («Локация / язык») — группы-теги рядом с keywords/stack/grade;
`types` — фиксированные чипы (вакансия/фриланс/объявление); `prices` — второй диапазон «Ограничить ценой» `types` — фиксированные чипы (вакансия/фриланс/объявление); `prices` — второй диапазон «Ограничить ценой»
с той же разметкой, что `budget` (обе секции рендерит общий цикл `RANGES`, без дублей). с той же разметкой, что `budget` (обе секции рендерит общий цикл `RANGES`, без дублей).
- Сборка правил: `buildRules` всегда кладёт `levels`/`locations`/`types` (пустые массивы — группа неактивна), - Сборка правил: `buildRules` всегда кладёт `levels`/`locations`/`types` (пустые массивы — группа неактивна),
`budget`/`prices` — только при заданных границах. Сохранение — прежним `saveBoardForm` (PATCH/POST `budget`/`prices` — только при заданных границах. Сохранение — прежним `saveBoardForm` (PATCH/POST
`/api/containers`), форма ответа совместима. `/api/containers`), форма ответа совместима.
- **Попутный фикс:** счётчик значений группы выводил сломанный литерал (`{{ ... }} < 5 ? 'значения' : ...`; - **Попутный фикс:** счётчик значений группы выводил сломанный литерал (`{{ ... }} < 5 ? 'значения' : ...`;
ключ `columns.form-g-key-length-1-znachenie-form-g-key`). Заменён на функцию `countWord` и три ключа ключ `columns.form-g-key-length-1-znachenie-form-g-key`). Заменён на функцию `countWord` и три ключа
`columns.znachenie/znacheniya/znachenij` (формы выбираются в коде — плюрализация в i18n не вводилась). `columns.znachenie/znacheniya/znachenij` (формы выбираются в коде — плюрализация в i18n не вводилась).
Это было видно на экране как «3 {{ form[g.key].length === 1 ? …» и затрагивало в т.ч. новые группы. Это было видно на экране как «3 {{ form[g.key].length === 1 ? …» и затрагивало в т.ч. новые группы.
- Подсказка диалога (`columns.kolonka-eto-nabor-opcionalnyh-filtrov`) дополнена перечислением новых групп. - Подсказка диалога (`columns.kolonka-eto-nabor-opcionalnyh-filtrov`) дополнена перечислением новых групп.
## 4. Оператор-консоль (новое в health и analytics) ## 4. Оператор-консоль (новое в health и analytics)
Файлы: `src/frontend/src/store/operator.js`, `src/frontend/src/views/operator/HealthSection.vue`, Файлы: `src/frontend/src/store/operator.js`, `src/frontend/src/views/operator/HealthSection.vue`,
`src/frontend/src/views/operator/AnalyticsSection.vue`. `src/frontend/src/views/operator/AnalyticsSection.vue`.
- **Состояние (§10.2).** Новая карточка «Очереди и сессии»: `queues.pipeline`, `queues.mlOutbox`, - **Состояние (§10.2).** Новая карточка «Очереди и сессии»: `queues.pipeline`, `queues.mlOutbox`,
`sessions.active` (плитки `StatCard`; тон `brand` при ненулевом значении). `sessions.active` (плитки `StatCard`; тон `brand` при ненулевом значении).
- **Подозрительная активность (§10.5).** В «Аналитике» — четвёртый подраздел «Подозрительная активность» - **Подозрительная активность (§10.5).** В «Аналитике» — четвёртый подраздел «Подозрительная активность»
(`GET /api/operator/analytics/suspicious?from=&to=`, общий период с обзором/токенами). Показывает (`GET /api/operator/analytics/suspicious?from=&to=`, общий период с обзором/токенами). Показывает
число разобранных записей, флаг `truncated` и список находок `{kind, severity, subject, count, detail}` число разобранных записей, флаг `truncated` и список находок `{kind, severity, subject, count, detail}`
с бейджем уровня (`high`→danger, `medium`→warn) и человекочитаемым названием правила. с бейджем уровня (`high`→danger, `medium`→warn) и человекочитаемым названием правила.
- Лоадер `loadSuspicious` добавлен по образцу `loadOverview`/`loadTokens`. - Лоадер `loadSuspicious` добавлен по образцу `loadOverview`/`loadTokens`.
--- ---
## Проверка ## Проверка
- `npm run build` — ✓ (`vite build`, 110 модулей). - `npm run build` — ✓ (`vite build`, 110 модулей).
- `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет). - `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет).
- Процессы не запускались; `netstat :5173` — порт свободен. - Процессы не запускались; `netstat :5173` — порт свободен.
## Найденные расхождения / замечания ## Найденные расхождения / замечания
1. **Недокументированный эндпоинт `suspicious`.** `GET /api/operator/analytics/suspicious` есть в коде 1. **Недокументированный эндпоинт `suspicious`.** `GET /api/operator/analytics/suspicious` есть в коде
(`OperatorAnalyticsEndpoints`) и в отчёте бэкенда, но отсутствует в `docs/architecture/2026-09-10-unified-api-contract.md` (`OperatorAnalyticsEndpoints`) и в отчёте бэкенда, но отсутствует в `docs/architecture/2026-09-10-unified-api-contract.md`
(там описан только `health`). Форма ответа взята из `SuspiciousActivityDto`. Док не правил (контракт менять нельзя). (там описан только `health`). Форма ответа взята из `SuspiciousActivityDto`. Док не правил (контракт менять нельзя).
2. **`ContainersService.NormalizeRules` (core, вне области) не учитывает новые группы** при решении 2. **`ContainersService.NormalizeRules` (core, вне области) не учитывает новые группы** при решении
«правил нет»: проверяются только `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`. На фронте не «правил нет»: проверяются только `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`. На фронте не
воспроизводится (UI всегда шлёт непустой `mode` = `all`/`any`), но латентно: правила только из воспроизводится (UI всегда шлёт непустой `mode` = `all`/`any`), но латентно: правила только из
`levels/locations/types/prices` с пустым `mode` будут обнулены. Код core не менялся — фиксирую как находку. `levels/locations/types/prices` с пустым `mode` будут обнулены. Код core не менялся — фиксирую как находку.
3. **Слепое пятно `scripts/i18n-lint.mjs`.** Незакрытый `<` в текстовом узле шаблона переводит остаток строки 3. **Слепое пятно `scripts/i18n-lint.mjs`.** Незакрытый `<` в текстовом узле шаблона переводит остаток строки
в «псевдотег», и кириллица после него не проверяется (именно так долго существовал сломанный счётчик в «псевдотег», и кириллица после него не проверяется (именно так долго существовал сломанный счётчик
в `BoardRulesDialog.vue`). Линтер не трогал; фикс UI выполнен, но сам пробел остаётся (кандидат на доработку в `BoardRulesDialog.vue`). Линтер не трогал; фикс UI выполнен, но сам пробел остаётся (кандидат на доработку
скрипта отдельной задачей). скрипта отдельной задачей).
4. **`grade` и `levels` семантически близки** («Грейд / уровень» и новый «Уровень»). Бэкенд держит их 4. **`grade` и `levels` семантически близки** («Грейд / уровень» и новый «Уровень»). Бэкенд держит их
отдельными группами — UI оставлен 1:1 с контрактом (ярлыки уточнены), но стоит подтвердить у владельца, отдельными группами — UI оставлен 1:1 с контрактом (ярлыки уточнены), но стоит подтвердить у владельца,
не дублируют ли они друг друга в его сценарии. не дублируют ли они друг друга в его сценарии.
5. **Типы заявок.** Для исключений/фильтров используются теги бэкенда `vacancy`/`freelance`/`announcement` 5. **Типы заявок.** Для исключений/фильтров используются теги бэкенда `vacancy`/`freelance`/`announcement`
(у `wantedType` в настройках — `both/vacancy/freelance`); сопоставление по смыслу выполнено в UI (чипы), (у `wantedType` в настройках — `both/vacancy/freelance`); сопоставление по смыслу выполнено в UI (чипы),
значения не смешиваются. значения не смешиваются.
## Что осталось (вне области этой задачи) ## Что осталось (вне области этой задачи)
- §11.12 переключатель языка (i18n архитектурно готов, UI-переключателя нет — решение владельца: только ru). - §11.12 переключатель языка (i18n архитектурно готов, UI-переключателя нет — решение владельца: только ru).
- §4.1/§8.1 `api_id/api_hash` как глобальная операторская настройка (сейчас — ключи тенанта). - §4.1/§8.1 `api_id/api_hash` как глобальная операторская настройка (сейчас — ключи тенанта).
- Живые интеграции Telegram/LLM, Cloudflare, k8s — вне кода фронта. - Живые интеграции Telegram/LLM, Cloudflare, k8s — вне кода фронта.
@@ -1,81 +1,81 @@
# Task report — «Внешний вид» (§8.12 ТЗ): светлая тема + раздел настроек # Task report — «Внешний вид» (§8.12 ТЗ): светлая тема + раздел настроек
## Что было ## Что было
Аудит ТЗ показал единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: в настройках Аудит ТЗ показал единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: в настройках
не было раздела оформления, тема поддерживалась только тёмная. не было раздела оформления, тема поддерживалась только тёмная.
## Что сделано (только `src/frontend`, без новых зависимостей) ## Что сделано (только `src/frontend`, без новых зависимостей)
### 1. Светлая тема через токены (`src/frontend/src/style.css`) ### 1. Светлая тема через токены (`src/frontend/src/style.css`)
- Тёмная палитра осталась дефолтом в блоке `@theme` — вид 1:1. - Тёмная палитра осталась дефолтом в блоке `@theme` — вид 1:1.
- Добавлен блок `:root[data-theme="light"]`, который переопределяет те же токены - Добавлен блок `:root[data-theme="light"]`, который переопределяет те же токены
(`--color-ink/panel/raise/hover/edge/hi/mid/low/brand/brand-2/online/danger/warn`), а также (`--color-ink/panel/raise/hover/edge/hi/mid/low/brand/brand-2/online/danger/warn`), а также
`--shadow-card/--shadow-pop`, цвет скроллбара (`--scrollbar-thumb`), линии фоновой сетки `--shadow-card/--shadow-pop`, цвет скроллбара (`--scrollbar-thumb`), линии фоновой сетки
(`--grid-line`), `color-scheme: light`. (`--grid-line`), `color-scheme: light`.
- Подобраны читаемые значения (напр. `ink #eef1f6`, `raise #fff`, `hi #131a26`, `mid #545e70`, - Подобраны читаемые значения (напр. `ink #eef1f6`, `raise #fff`, `hi #131a26`, `mid #545e70`,
`brand #5f57ea`, `online #16a34a`, `danger #dc2626`, `warn #b45309`). `brand #5f57ea`, `online #16a34a`, `danger #dc2626`, `warn #b45309`).
- Компоненты не переписывались — все утилиты (`bg-ink`, `text-hi`, …) ссылаются на токены. - Компоненты не переписывались — все утилиты (`bg-ink`, `text-hi`, …) ссылаются на токены.
### 2. Полупрозрачные слои `bg-white/N` / `border-white/N` ### 2. Полупрозрачные слои `bg-white/N` / `border-white/N`
- В светлой теме токен `--color-white` намеренно указывает на тёмный оттенок (`#0b1220`): белые - В светлой теме токен `--color-white` намеренно указывает на тёмный оттенок (`#0b1220`): белые
подсветки/разделители/hover становятся мягкими серыми. Тёмная тема не затронута. подсветки/разделители/hover становятся мягкими серыми. Тёмная тема не затронута.
- Литеральные `text-white`/`bg-white` (текст на бренд-градиенте, фон QR-кода, ползунок тумблера) - Литеральные `text-white`/`bg-white` (текст на бренд-градиенте, фон QR-кода, ползунок тумблера)
сохранены белыми отдельными правилами. сохранены белыми отдельными правилами.
- Добавлены токены `--color-on-brand` / `--color-on-warn` (цвет текста поверх сплошной заливки), - Добавлены токены `--color-on-brand` / `--color-on-warn` (цвет текста поверх сплошной заливки),
применены в бейджах счётчиков (`Card.vue`, `Sidebar.vue`) и кнопке напоминания применены в бейджах счётчиков (`Card.vue`, `Sidebar.vue`) и кнопке напоминания
(`HoldReminderDialog.vue`). (`HoldReminderDialog.vue`).
- `.tgmd` (подложки code/pre/spoiler) получили светлые значения; скроллбар и линии сетки — через - `.tgmd` (подложки code/pre/spoiler) получили светлые значения; скроллбар и линии сетки — через
переменные. `accent-[#8b8ff8]``accent-brand` (4 места); убраны инлайн `style="color-scheme: dark"` переменные. `accent-[#8b8ff8]``accent-brand` (4 места); убраны инлайн `style="color-scheme: dark"`
(теперь наследуется от `html`, который переключается темой). (теперь наследуется от `html`, который переключается темой).
### 3. Раздел «Внешний вид» в настройках ### 3. Раздел «Внешний вид» в настройках
- Новая вкладка `appearance` в `SettingsView.vue` рядом с «Уведомлениями»; компонент - Новая вкладка `appearance` в `SettingsView.vue` рядом с «Уведомлениями»; компонент
`src/frontend/src/components/settings/AppearanceTab.vue`. `src/frontend/src/components/settings/AppearanceTab.vue`.
- Три варианта: «Тёмная» (по умолчанию) / «Светлая» / «Системная». Переключение мгновенное, тост - Три варианта: «Тёмная» (по умолчанию) / «Светлая» / «Системная». Переключение мгновенное, тост
«Тема обновлена». Выбранный вариант визуально отмечен (рамка/галочка). «Тема обновлена». Выбранный вариант визуально отмечен (рамка/галочка).
- Новые иконки в `Icon.vue`: `palette`, `moon`, `sun`, `monitor`. - Новые иконки в `Icon.vue`: `palette`, `moon`, `sun`, `monitor`.
### 4. Хранение и отсутствие «мигания» ### 4. Хранение и отсутствие «мигания»
- `src/frontend/src/composables/theme.js`: значения `dark|light|system`, ключ `localStorage` - `src/frontend/src/composables/theme.js`: значения `dark|light|system`, ключ `localStorage`
`leadradar_theme`; `setTheme()` меняет `data-theme` на `<html>` и сохраняет выбор; режим `system` `leadradar_theme`; `setTheme()` меняет `data-theme` на `<html>` и сохраняет выбор; режим `system`
слушает `prefers-color-scheme`. слушает `prefers-color-scheme`.
- Инлайн-скрипт в `src/frontend/index.html` применяет тему **до первого рендера** (дублирует логику - Инлайн-скрипт в `src/frontend/index.html` применяет тему **до первого рендера** (дублирует логику
composable, ключ/значения совпадают) — вспышки тёмной темы нет. composable, ключ/значения совпадают) — вспышки тёмной темы нет.
### 5. Строки i18n ### 5. Строки i18n
Новые ключи в `src/i18n/locales/ru.js` (`settings.vneshnij-vid`, `settings.tema-oformleniya`, Новые ключи в `src/i18n/locales/ru.js` (`settings.vneshnij-vid`, `settings.tema-oformleniya`,
`settings.tema-oformleniya-opisanie`, `settings.tema-temnaya[-opisanie]`, `settings.tema-oformleniya-opisanie`, `settings.tema-temnaya[-opisanie]`,
`settings.tema-svetlaya[-opisanie]`, `settings.tema-sistemnaya[-opisanie]`, `settings.tema-primenena`). `settings.tema-svetlaya[-opisanie]`, `settings.tema-sistemnaya[-opisanie]`, `settings.tema-primenena`).
Хардкода кириллицы нет. Хардкода кириллицы нет.
### 6. Документация (вне `src/frontend`, единственная правка) ### 6. Документация (вне `src/frontend`, единственная правка)
`docs/technical/Техническая-документация-Дейл.md` — новый раздел §15 «Темы оформления»: токены, как `docs/technical/Техническая-документация-Дейл.md` — новый раздел §15 «Темы оформления»: токены, как
устроена светлая тема, полупрозрачные слои, где хранится выбор. устроена светлая тема, полупрозрачные слои, где хранится выбор.
## Как проверял ## Как проверял
- `cd C:\telbase\src\frontend``npm run build`**зелёно** (`✓ built`, 110 модулей; чанки - `cd C:\telbase\src\frontend``npm run build`**зелёно** (`✓ built`, 110 модулей; чанки
vendor/i18n/index, без предупреждений о размере). vendor/i18n/index, без предупреждений о размере).
- `npm run lint:i18n`**зелёно** (`✓ кириллических пользовательских строк вне словарей не найдено`). - `npm run lint:i18n`**зелёно** (`✓ кириллических пользовательских строк вне словарей не найдено`).
- Проверил скомпилированный CSS: блок `:root[data-theme=light]` присутствует, утилиты `bg-white/N` - Проверил скомпилированный CSS: блок `:root[data-theme=light]` присутствует, утилиты `bg-white/N`
по-прежнему используют `var(--color-white)` (значит, следуют теме), `.text-on-brand` сгенерирован. по-прежнему используют `var(--color-white)` (значит, следуют теме), `.text-on-brand` сгенерирован.
- Статический аудит цветов по всем экранам: помимо токенов встречаются только `bg-black/4560` - Статический аудит цветов по всем экранам: помимо токенов встречаются только `bg-black/4560`
(затемнение модалок — уместно в обеих темах) и один `to-[#d97706]` (янтарный градиент). Прочих (затемнение модалок — уместно в обеих темах) и один `to-[#d97706]` (янтарный градиент). Прочих
«серых/белых» хардкодов нет. «серых/белых» хардкодов нет.
- Диагностика IDE по новым/изменённым файлам — без ошибок (в `style.css` только унаследованные - Диагностика IDE по новым/изменённым файлам — без ошибок (в `style.css` только унаследованные
предупреждения про `@theme` и `line-clamp`). предупреждения про `@theme` и `line-clamp`).
Тёмная тема не менялась: все новые правила либо под `:root[data-theme="light"]`, либо заменяют Тёмная тема не менялась: все новые правила либо под `:root[data-theme="light"]`, либо заменяют
токен на эквивалентное значение (`text-ink` на бренд-бейдже → `text-on-brand` == `#0a0d12` в тёмной; токен на эквивалентное значение (`text-ink` на бренд-бейдже → `text-on-brand` == `#0a0d12` в тёмной;
`accent-brand` == `#8b8ff8` в тёмной; удалённые `color-scheme: dark` наследуются от `html`). `accent-brand` == `#8b8ff8` в тёмной; удалённые `color-scheme: dark` наследуются от `html`).
## Как проверял визуально ## Как проверял визуально
Автоматизированного браузерного прогона нет (нет соответствующих зависимостей, добавлять запрещено), Автоматизированного браузерного прогона нет (нет соответствующих зависимостей, добавлять запрещено),
поэтому проверка светлой темы — статический аудит токенов/утилит по основным экранам: дашборд/колонки/ поэтому проверка светлой темы — статический аудит токенов/утилит по основным экранам: дашборд/колонки/
карточки, drawer, настройки (включая новую вкладку), оператор-консоль, страница активации, вход. карточки, drawer, настройки (включая новую вкладку), оператор-консоль, страница активации, вход.
Скриншотная проверка в браузере — на стороне владельца. Скриншотная проверка в браузере — на стороне владельца.
## Что осталось / на что обратить внимание ## Что осталось / на что обратить внимание
- Полная визуальная приёмка светлой темы в браузере (руками): контрасты на реальных данных, - Полная визуальная приёмка светлой темы в браузере (руками): контрасты на реальных данных,
пользовательские цвета колонок (задаются динамически, тема их не меняет). пользовательские цвета колонок (задаются динамически, тема их не меняет).
- Возможный фоллоу-ап: если понадобится ещё язык/тема — новых зависимостей не требуется, - Возможный фоллоу-ап: если понадобится ещё язык/тема — новых зависимостей не требуется,
архитектура готова (словари + токены). архитектура готова (словари + токены).
@@ -1,55 +1,55 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage2-settings.md # SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage2-settings.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам. Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos ## Todos
- Task 1: complete (review clean; 38 PASS; Decrypt без `enc:` → "" — приемлемо, legacy-данных нет). Отчёт: task-1-report.md. - Task 1: complete (review clean; 38 PASS; Decrypt без `enc:` → "" — приемлемо, legacy-данных нет). Отчёт: task-1-report.md.
- [x] Task 1: Шифрование секретов (AES-GCM) - [x] Task 1: Шифрование секретов (AES-GCM)
- Task 2: complete (review clean; каталог ключей полный 1:1 с прототипом; 51 PASS). Отчёт: task-2-report.md. - Task 2: complete (review clean; каталог ключей полный 1:1 с прототипом; 51 PASS). Отчёт: task-2-report.md.
- [x] Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища - [x] Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища
- Task 3: complete (review clean; снимок+PATCH 1:1; 87 PASS). Отчёт: task-3-report.md. - Task 3: complete (review clean; снимок+PATCH 1:1; 87 PASS). Отчёт: task-3-report.md.
- Task 3: complete (review clean; 87 PASS; delay-клампы {5,600} — приёмка T5 должна ожидать это, не {5,700}). Отчёт: task-3-report.md. - Task 3: complete (review clean; 87 PASS; delay-клампы {5,600} — приёмка T5 должна ожидать это, не {5,700}). Отчёт: task-3-report.md.
- [x] Task 3: SettingsService — public-снимок и PATCH 1:1 - [x] Task 3: SettingsService — public-снимок и PATCH 1:1
- Task 4: complete (review clean; build 0/0, 88 PASS; dev-check psql: дефолты на пустой схеме + SetAsync создал строку value_json; схема devcheck_t4 удалена). Отчёт: task-4-report.md. - Task 4: complete (review clean; build 0/0, 88 PASS; dev-check psql: дефолты на пустой схеме + SetAsync создал строку value_json; схема devcheck_t4 удалена). Отчёт: task-4-report.md.
- Task 4: complete (build 0/0; 88 PASS; dev-check на deal-postgres 24/24). Отчёт: task-4-report.md. - Task 4: complete (build 0/0; 88 PASS; dev-check на deal-postgres 24/24). Отчёт: task-4-report.md.
- [x] Task 4: KV-адаптер SettingsStore (EF) и DI - [x] Task 4: KV-адаптер SettingsStore (EF) и DI
- Task 5: complete (build 0/0, 88 PASS; curl-приёмка :5080 — PASS=42 FAIL=0; psql: enc: в aiConfigs/tgKeys; delay-клампы {5,600}; dev-БД очищена после прогона). Отчёт: task-5-report.md. - Task 5: complete (build 0/0, 88 PASS; curl-приёмка :5080 — PASS=42 FAIL=0; psql: enc: в aiConfigs/tgKeys; delay-клампы {5,600}; dev-БД очищена после прогона). Отчёт: task-5-report.md.
- Task 5: complete (review clean; curl 42/42; delay {5,600}; enc: в psql). Отчёт: task-5-report.md. Note: при 3-м Endpoints-файле — общий HTTP-хелпер (401/400); DecoderFallbackException → 400. - Task 5: complete (review clean; curl 42/42; delay {5,600}; enc: в psql). Отчёт: task-5-report.md. Note: при 3-м Endpoints-файле — общий HTTP-хелпер (401/400); DecoderFallbackException → 400.
- [x] Task 5: Эндпоинты GET/PATCH /api/settings + curl-приёмка - [x] Task 5: Эндпоинты GET/PATCH /api/settings + curl-приёмка
- Task 6: complete (build 0/0, 98 PASS; +10 AiConnectionCheckerTests; curl :5080 — PASS=23 FAIL=0: без ключа → «Не задан API-ключ», недоступный порт deepseek → «Ошибка соединения», ollama → «Локальный сервер…», SSRF-гейт ftp-схемы; общий EndpointResults 401/400; dev-БД очищена). Отчёт: task-6-report.md. - Task 6: complete (build 0/0, 98 PASS; +10 AiConnectionCheckerTests; curl :5080 — PASS=23 FAIL=0: без ключа → «Не задан API-ключ», недоступный порт deepseek → «Ошибка соединения», ollama → «Локальный сервер…», SSRF-гейт ftp-схемы; общий EndpointResults 401/400; dev-БД очищена). Отчёт: task-6-report.md.
- Task 6: complete (review clean; 98 PASS; curl 23/23). Отчёт: task-6-report.md. Note: прод-SSRF — host-allowlist + запрет авто-редиректов. - Task 6: complete (review clean; 98 PASS; curl 23/23). Отчёт: task-6-report.md. Note: прод-SSRF — host-allowlist + запрет авто-редиректов.
- [x] Task 6: ИИ-провайдеры и POST /api/ai/check - [x] Task 6: ИИ-провайдеры и POST /api/ai/check
- Task 7: complete (build 0/0, 113 PASS; +15 PromptDefaultsTests; сверка промптов 3/3 идентичны data.js построчно; curl :5080 — PASS=19 FAIL=0: PATCH/GET aiPrompt с плейсхолдерами 1:1, myPrompts 3 записи camelCase на месте, logout→401; dev-БД очищена). Отчёт: task-7-report.md. - Task 7: complete (build 0/0, 113 PASS; +15 PromptDefaultsTests; сверка промптов 3/3 идентичны data.js построчно; curl :5080 — PASS=19 FAIL=0: PATCH/GET aiPrompt с плейсхолдерами 1:1, myPrompts 3 записи camelCase на месте, logout→401; dev-БД очищена). Отчёт: task-7-report.md.
- Task 7: complete (review clean; 113 PASS; curl 19/19). Отчёт: task-7-report.md. Note: Git-Bash искажает кириллицу в args curl.exe (cp1251) — JSON-тела curl-приёмки читаются из UTF-8-файлов (--data-binary @file). - Task 7: complete (review clean; 113 PASS; curl 19/19). Отчёт: task-7-report.md. Note: Git-Bash искажает кириллицу в args curl.exe (cp1251) — JSON-тела curl-приёмки читаются из UTF-8-файлов (--data-binary @file).
- Task 7: complete (113 PASS; промпты 3/3 идентичны data.js; /api/prompts* не нужны). Отчёт: task-7-report.md. - Task 7: complete (113 PASS; промпты 3/3 идентичны data.js; /api/prompts* не нужны). Отчёт: task-7-report.md.
- [x] Task 7: Промпты и «Мои промпты» - [x] Task 7: Промпты и «Мои промпты»
- Task 8: complete (build 0/0; 148 PASS; +35 RatesServiceTests/CbrRateSourceTests; curl :5080 — PASS=22 FAIL=0: 401, дефолт-мок без кэша (source mock/updatedAt null), ratesCache не публикуется в /settings, PATCH mock→refresh ok:true→GET тот же кэш, psql {rates,source,updatedAtMs}, реальный ЦБ ok:true (USD 86.5857), logout→401; dev-БД очищена). Отчёт: task-8-report.md. - Task 8: complete (build 0/0; 148 PASS; +35 RatesServiceTests/CbrRateSourceTests; curl :5080 — PASS=22 FAIL=0: 401, дефолт-мок без кэша (source mock/updatedAt null), ratesCache не публикуется в /settings, PATCH mock→refresh ok:true→GET тот же кэш, psql {rates,source,updatedAtMs}, реальный ЦБ ok:true (USD 86.5857), logout→401; dev-БД очищена). Отчёт: task-8-report.md.
- Task 8: complete (review clean; 148 PASS; curl 22/22). Отчёт: task-8-report.md. Note: rateSource дефолт "cbr", дефолт ответа без кэша — мок; PATCH-хук (Ruling 6) в SettingsEndpoints (HTTP-слой), фон — RatesRefreshScheduler (Api, свой scope + in-flight guard); ShouldFetch симметричен (смена источника обе стороны); cbr-URL фиксирован (SSRF); AddHttpClient typed client transient (как Task 6). - Task 8: complete (review clean; 148 PASS; curl 22/22). Отчёт: task-8-report.md. Note: rateSource дефолт "cbr", дефолт ответа без кэша — мок; PATCH-хук (Ruling 6) в SettingsEndpoints (HTTP-слой), фон — RatesRefreshScheduler (Api, свой scope + in-flight guard); ShouldFetch симметричен (смена источника обе стороны); cbr-URL фиксирован (SSRF); AddHttpClient typed client transient (как Task 6).
- Task 8: complete (review clean; 148 PASS; curl 22/22; cbr живой). Отчёт: task-8-report.md. - Task 8: complete (review clean; 148 PASS; curl 22/22; cbr живой). Отчёт: task-8-report.md.
- [x] Task 8: Курсы валют — сервис, кэш, /api/rates* - [x] Task 8: Курсы валют — сервис, кэш, /api/rates*
- Task 9: complete (build 0/0; 161 PASS; +13 LocalMlClientTests; curl :5080 — PASS=31 FAIL=0: 401 без сессии, status форма §4.10 1:1 (reachable:true/ready:false/outbox:0), mlEnabled false→enabled:false, predict «x»→400 «Введите текст», predict с текстом → take:false/.../type:null, reset {ok:true} без error, candidates {items:[]}, apply 404, psql: нет ml_outbox/learning_log, logout→401; dev-БД очищена). Отчёт: task-9-report.md. - Task 9: complete (build 0/0; 161 PASS; +13 LocalMlClientTests; curl :5080 — PASS=31 FAIL=0: 401 без сессии, status форма §4.10 1:1 (reachable:true/ready:false/outbox:0), mlEnabled false→enabled:false, predict «x»→400 «Введите текст», predict с текстом → take:false/.../type:null, reset {ok:true} без error, candidates {items:[]}, apply 404, psql: нет ml_outbox/learning_log, logout→401; dev-БД очищена). Отчёт: task-9-report.md.
- Task 9: complete (review clean; 161 PASS; curl 31/31). Отчёт: task-9-report.md. - Task 9: complete (review clean; 161 PASS; curl 31/31). Отчёт: task-9-report.md.
- [x] Task 9: ML-панель — IMlClient, заглушка, /api/ml - [x] Task 9: ML-панель — IMlClient, заглушка, /api/ml
- Task 10: complete (build 0/0; 175 PASS; +14 IncomingRulesTests; curl :5080 — PASS=25 FAIL=0: 401 без куки, дефолты «Заработок на крипте…» → stage1.pass:true/stage2.skipped:true/passed:true, пустой текст → «короче 24 символов», PATCH stopPhrases=[взаимный пиар]+minLen=10 → «стоп-фраза «взаимный пиар»»/passed:false, «Ищу работу python…» → «резюме соискателя («ищу работу»)/passed:false, валидный → passed:true, logout→401; kind/kw — внутри IncomingRules (wire 1:1 §4.10); dev-БД очищена). Отчёт: task-10-report.md. - Task 10: complete (build 0/0; 175 PASS; +14 IncomingRulesTests; curl :5080 — PASS=25 FAIL=0: 401 без куки, дефолты «Заработок на крипте…» → stage1.pass:true/stage2.skipped:true/passed:true, пустой текст → «короче 24 символов», PATCH stopPhrases=[взаимный пиар]+minLen=10 → «стоп-фраза «взаимный пиар»»/passed:false, «Ищу работу python…» → «резюме соискателя («ищу работу»)/passed:false, валидный → passed:true, logout→401; kind/kw — внутри IncomingRules (wire 1:1 §4.10); dev-БД очищена). Отчёт: task-10-report.md.
- Task 10: complete (review clean; 175 PASS; curl 25/25). Отчёт: task-10-report.md. - Task 10: complete (review clean; 175 PASS; curl 25/25). Отчёт: task-10-report.md.
- [x] Task 10: Тестер фильтров — IncomingRules и /api/admin/check-message - [x] Task 10: Тестер фильтров — IncomingRules и /api/admin/check-message
- Task 11: complete (review pending). Отчёт: task-11-report.md. - Task 11: complete (review pending). Отчёт: task-11-report.md.
- Task 11: complete (review clean; сквозная приёмка 60/60). Отчёт: task-11-report.md. - Task 11: complete (review clean; сквозная приёмка 60/60). Отчёт: task-11-report.md.
- **Этап 2 завершён**: финальное whole-scope ревью ✅ (build 0/0, 175 PASS, миграций новых нет, docs/roadmap актуальны). Note в этап 3: проверить, что PATCH aiConfigs не шлёт keyMasked обратно как apiKey; SSRF-контур ai/check — прод-ужесточение позже. - **Этап 2 завершён**: финальное whole-scope ревью ✅ (build 0/0, 175 PASS, миграций новых нет, docs/roadmap актуальны). Note в этап 3: проверить, что PATCH aiConfigs не шлёт keyMasked обратно как apiKey; SSRF-контур ai/check — прод-ужесточение позже.
- [x] Task 11: Финал этапа — интеграция и сквозная приёмка - [x] Task 11: Финал этапа — интеграция и сквозная приёмка
## Pre-flight scan ## Pre-flight scan
| Пара | Производит / потребляет | Результат | | Пара | Производит / потребляет | Результат |
|---|---|---| |---|---|---|
| T1 → T3/T6 | ISecretCipher потребляется SettingsService/ai check | Чисто | | T1 → T3/T6 | ISecretCipher потребляется SettingsService/ai check | Чисто |
| T2 → T3/T4 | каталог ключей + дефолты + ISettingsStore → сервис/адаптер | Чисто | | T2 → T3/T4 | каталог ключей + дефолты + ISettingsStore → сервис/адаптер | Чисто |
| T3 → T5 | SettingsService → эндпоинты | Чисто | | T3 → T5 | SettingsService → эндпоинты | Чисто |
| T4 → T5 | DI адаптера | Чисто | | T4 → T5 | DI адаптера | Чисто |
| T8 → T2/T3 | ratesCache — внутренний KV-ключ через ISettingsStore | Чисто (внутренние ключи не публичны) | | T8 → T2/T3 | ratesCache — внутренний KV-ключ через ISettingsStore | Чисто (внутренние ключи не публичны) |
| T9 | Contracts/Integrations IMlClient — новый проект Contracts наполняется | Проверить ссылки (Contracts уже referenced) | | T9 | Contracts/Integrations IMlClient — новый проект Contracts наполняется | Проверить ссылки (Contracts уже referenced) |
| T10 | IncomingRules — переиспользуется этапом 4 | Чисто | | T10 | IncomingRules — переиспользуется этапом 4 | Чисто |
| T6/T7 | HTTP наружу (ai/check), cbr (rates) | SSRF-риск: только baseUrl из настроек тенанта (allowlist провайдеров) — следить в ревью | | T6/T7 | HTTP наружу (ai/check), cbr (rates) | SSRF-риск: только baseUrl из настроек тенанта (allowlist провайдеров) — следить в ревью |
| T2 | новые EF-таблицы не создаются | Таблица settings существует | | T2 | новые EF-таблицы не создаются | Таблица settings существует |
## Task status ## Task status
@@ -1,99 +1,99 @@
# Task 1 — «Шифрование секретов (AES-GCM)» — отчёт # Task 1 — «Шифрование секретов (AES-GCM)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 38/38 PASS (было 25, добавлено 13). Статус: **complete**. Build 0 warnings / 0 errors; тесты 38/38 PASS (было 25, добавлено 13).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 1 L126148, Ruling 2 L5965). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 1 L126148, Ruling 2 L5965).
## Файлы ## Файлы
### Созданы ### Созданы
- `src/core/Deal.Modules.Settings/Application/ISecretCipher.cs` — порт в модуле Settings - `src/core/Deal.Modules.Settings/Application/ISecretCipher.cs` — порт в модуле Settings
(namespace `Deal.Modules.Settings.Application`): `string Encrypt(string plainText)`, (namespace `Deal.Modules.Settings.Application`): `string Encrypt(string plainText)`,
`string Decrypt(string cipherText)`. XML-doc фиксирует формат `enc:` + Base64(nonce ‖ ct ‖ tag) `string Decrypt(string cipherText)`. XML-doc фиксирует формат `enc:` + Base64(nonce ‖ ct ‖ tag)
и контракт: Decrypt повреждённого/чужого значения → пустая строка **без исключений**. и контракт: Decrypt повреждённого/чужого значения → пустая строка **без исключений**.
Модуль НЕ получил новых ссылок — интерфейс чистый (BCL). Grep по папке модуля: Модуль НЕ получил новых ссылок — интерфейс чистый (BCL). Grep по папке модуля:
`Infrastructure|EntityFramework|Npgsql`**0 совпадений** (модуль остался чистым). `Infrastructure|EntityFramework|Npgsql`**0 совпадений** (модуль остался чистым).
- `src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs``public sealed`, реализует - `src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs``public sealed`, реализует
`ISecretCipher`. AES-256-GCM (`System.Security.Cryptography.AesGcm`), ключ 32 байта передаётся `ISecretCipher`. AES-256-GCM (`System.Security.Cryptography.AesGcm`), ключ 32 байта передаётся
в конструктор; именованные константы: `NonceSizeBytes = 12`, `TagSizeBytes = 16`, в конструктор; именованные константы: `NonceSizeBytes = 12`, `TagSizeBytes = 16`,
`KeySizeBytes = 32`, `EncryptedPrefix = "enc:"`. `KeySizeBytes = 32`, `EncryptedPrefix = "enc:"`.
- `src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs``public sealed`, разрешает - `src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs``public sealed`, разрешает
ключ по Ruling 2, кэширует после первого разрешения (`GetKey()`), ctor принимает ContentRoot. ключ по Ruling 2, кэширует после первого разрешения (`GetKey()`), ctor принимает ContentRoot.
- `src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs` — 13 тест-кейсов (см. ниже). - `src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs` — 13 тест-кейсов (см. ниже).
- `.superpowers/sdd/deal-stage2-settings/task-1-report.md` — этот отчёт. - `.superpowers/sdd/deal-stage2-settings/task-1-report.md` — этот отчёт.
### Изменены ### Изменены
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на - `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
`Deal.Modules.Settings` (по образцу ссылки на `Deal.Modules.Tenants` — реализация порта модуля). `Deal.Modules.Settings` (по образцу ссылки на `Deal.Modules.Tenants` — реализация порта модуля).
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` — добавлен метод - `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` — добавлен метод
`AddDealSecurity(this IServiceCollection services, string contentRootPath)`. `AddDealSecurity(this IServiceCollection services, string contentRootPath)`.
- `src/core/Deal.Api/Program.cs` — вызов `builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);` - `src/core/Deal.Api/Program.cs` — вызов `builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);`
рядом с `AddDealPersistence()` (как Program.cs регистрирует Infrastructure). рядом с `AddDealPersistence()` (как Program.cs регистрирует Infrastructure).
## Решения ## Решения
### DI: отдельный `AddDealSecurity`, а не `AddDealPersistence` ### DI: отдельный `AddDealSecurity`, а не `AddDealPersistence`
`AddDealPersistence` — регистрация scoped-EF-адаптеров (порт/адаптер персистентности), а шифрование — `AddDealPersistence` — регистрация scoped-EF-адаптеров (порт/адаптер персистентности), а шифрование —
не персистентность. Добавлен отдельный метод в тот же статический класс `ServiceCollectionExtensions` не персистентность. Добавлен отдельный метод в тот же статический класс `ServiceCollectionExtensions`
(Infrastructure, точка регистрации как у `AddDealPersistence`), вызывается в `Program.cs` той же строкой-соседом. (Infrastructure, точка регистрации как у `AddDealPersistence`), вызывается в `Program.cs` той же строкой-соседом.
`ISecretCipher` регистрируется **singleton** (реализация без разделяемого состояния — потокобезопасна). `ISecretCipher` регистрируется **singleton** (реализация без разделяемого состояния — потокобезопасна).
Сигнатура `(IServiceCollection, string contentRootPath)` вместо предложенной в ТЗ Сигнатура `(IServiceCollection, string contentRootPath)` вместо предложенной в ТЗ
`(IServiceCollection, IConfiguration)`: провайдеру нужен ContentRoot (каталог файла-ключа), которого `(IServiceCollection, IConfiguration)`: провайдеру нужен ContentRoot (каталог файла-ключа), которого
в `IConfiguration` нет; env-переменные (`DEAL_ENCRYPTION_KEY`, `DEAL_ENCRYPTION_KEY_FILE`) провайдер в `IConfiguration` нет; env-переменные (`DEAL_ENCRYPTION_KEY`, `DEAL_ENCRYPTION_KEY_FILE`) провайдер
читает из окружения напрямую — они не проходят через секции конфигурации. читает из окружения напрямую — они не проходят через секции конфигурации.
### EncryptionKeyProvider — вне DI (обоснование) ### EncryptionKeyProvider — вне DI (обоснование)
Ключ разрешается **один раз при вызове `AddDealSecurity`** (на старте приложения) и сразу передаётся Ключ разрешается **один раз при вызове `AddDealSecurity`** (на старте приложения) и сразу передаётся
в конструктор `AesGcmSecretCipher`; провайдер после этого рантайм-сервисам не нужен. Преимущества: в конструктор `AesGcmSecretCipher`; провайдер после этого рантайм-сервисам не нужен. Преимущества:
(а) невалидный env-ключ останавливает запуск (план Task 1: «невалидный env-ключ → исключение при (а) невалидный env-ключ останавливает запуск (план Task 1: «невалидный env-ключ → исключение при
старте», семантика `crypto._get_fernet`); (б) контейнер не хранит лишнего состояния и зависимостей. старте», семантика `crypto._get_fernet`); (б) контейнер не хранит лишнего состояния и зависимостей.
Порядок разрешения — как в `crypto.py L2242`: env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64, Порядок разрешения — как в `crypto.py L2242`: env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64,
декодирование терпимо к urlsafe-алфавиту и отсутствию padding) → иначе файл декодирование терпимо к urlsafe-алфавиту и отсутствию padding) → иначе файл
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`); при первом `<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`); при первом
старте файл генерируется (`RandomNumberGenerator`, 32 случайных байта, запись urlsafe-Base64). старте файл генерируется (`RandomNumberGenerator`, 32 случайных байта, запись urlsafe-Base64).
Ошибки файла/окружения оборачиваются в `InvalidOperationException` с понятным сообщением. Ошибки файла/окружения оборачиваются в `InvalidOperationException` с понятным сообщением.
### `.NET 10` AesGcm API (зафиксировано) ### `.NET 10` AesGcm API (зафиксировано)
Использован конструктор `new AesGcm(key, tagSizeInBytes)` — явный размер тега 16 (без неявного Использован конструктор `new AesGcm(key, tagSizeInBytes)` — явный размер тега 16 (без неявного
дефолта, который в .NET 8+ помечен SYSLIB0053). Nonce — 12 байт, генерируется дефолта, который в .NET 8+ помечен SYSLIB0053). Nonce — 12 байт, генерируется
`RandomNumberGenerator.GetBytes`. One-shot-вызовы `Encrypt(nonce, plain, cipher, tag)` / `RandomNumberGenerator.GetBytes`. One-shot-вызовы `Encrypt(nonce, plain, cipher, tag)` /
`Decrypt(nonce, cipher, tag, plain)` со спан-слайсами payload'а; повреждённый tag → `Decrypt(nonce, cipher, tag, plain)` со спан-слайсами payload'а; повреждённый tag →
`AuthenticationTagMismatchException` (подкласс `CryptographicException`) → возврат `""`. `AuthenticationTagMismatchException` (подкласс `CryptographicException`) → возврат `""`.
Экземпляр `AesGcm` создаётся на операцию (операции с секретами редкие; отсутствие разделяемого Экземпляр `AesGcm` создаётся на операцию (операции с секретами редкие; отсутствие разделяемого
состояния снимает вопросы потокобезопасности singleton). состояния снимает вопросы потокобезопасности singleton).
### Тесты (`SecretCipherTests`, фиксированный 32-байтовый ключ 1..32) ### Тесты (`SecretCipherTests`, фиксированный 32-байтовый ключ 1..32)
1. Roundtrip Encrypt→Decrypt для кириллицы, спецсимволов, URL и пустой строки (4 кейса) — исходная строка. 1. Roundtrip Encrypt→Decrypt для кириллицы, спецсимволов, URL и пустой строки (4 кейса) — исходная строка.
2. `Encrypt("")``""`. 2. `Encrypt("")``""`.
3. Непустой Encrypt даёт токен с префиксом `enc:`. 3. Непустой Encrypt даёт токен с префиксом `enc:`.
4. Два Encrypt одной строки — разные токены (случайный nonce). 4. Два Encrypt одной строки — разные токены (случайный nonce).
5. Decrypt строки без префикса `enc:``""`. 5. Decrypt строки без префикса `enc:``""`.
6. Decrypt мусора: `enc:`, `enc:не-base64!`, payload короче nonce+tag (`enc:AAAA`) → `""` без исключений. 6. Decrypt мусора: `enc:`, `enc:не-base64!`, payload короче nonce+tag (`enc:AAAA`) → `""` без исключений.
7. Decrypt токена с повреждённым последним байтом тега → `""`. 7. Decrypt токена с повреждённым последним байтом тега → `""`.
8. Decrypt токена, зашифрованного чужим ключом → `""`. 8. Decrypt токена, зашифрованного чужим ключом → `""`.
## Проверки (выводы) ## Проверки (выводы)
``` ```
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (все 10 проектов) dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (все 10 проектов)
dotnet test tests/Deal.Tests.Unit --no-build dotnet test tests/Deal.Tests.Unit --no-build
Сводка теста: всего: 38; сбой: 0; успешно: 38; пропущено: 0 (было 25 → +13) Сводка теста: всего: 38; сбой: 0; успешно: 38; пропущено: 0 (было 25 → +13)
``` ```
## Отклонения от кода плана ## Отклонения от кода плана
1. **Decrypt строки без префикса `enc:` возвращает `""`, а не «как есть».** План Task 1 1. **Decrypt строки без префикса `enc:` возвращает `""`, а не «как есть».** План Task 1
(L130) и `crypto.decrypt_text` (L55–56) предписывают passthrough незашифрованных значений (L130) и `crypto.decrypt_text` (L55–56) предписывают passthrough незашифрованных значений
«ранних версий». ТЗ задачи (список тестов, п. 5) явно требует обратное: «Decrypt строки без «ранних версий». ТЗ задачи (список тестов, п. 5) явно требует обратное: «Decrypt строки без
префикса `enc:` → пустая строка». Реализовано по ТЗ: значение без префикса трактуется как префикса `enc:` → пустая строка». Реализовано по ТЗ: значение без префикса трактуется как
«чужое» (Ruling 2) — в новой .NET-БД legacy-значений нет, все секреты пишутся через Encrypt. «чужое» (Ruling 2) — в новой .NET-БД legacy-значений нет, все секреты пишутся через Encrypt.
Если passthrough понадобится позже (миграция старых данных) — это однострочное изменение. Если passthrough понадобится позже (миграция старых данных) — это однострочное изменение.
2. **`MaybeEncrypt` и `EncryptionOptions.cs` из плана не создавались** — ТЗ задачи задаёт 2. **`MaybeEncrypt` и `EncryptionOptions.cs` из плана не создавались** — ТЗ задачи задаёт
интерфейс только из `Encrypt`/`Decrypt`; пустые значения обрабатывает сам `Encrypt` (→ `""`, интерфейс только из `Encrypt`/`Decrypt`; пустые значения обрабатывает сам `Encrypt` (→ `""`,
семантика `crypto.encrypt_text`). IOptions-секция для пути файла-ключа не нужна: путь задаётся семантика `crypto.encrypt_text`). IOptions-секция для пути файла-ключа не нужна: путь задаётся
env `DEAL_ENCRYPTION_KEY_FILE` + ContentRoot из конструктора. env `DEAL_ENCRYPTION_KEY_FILE` + ContentRoot из конструктора.
3. **Warning-лог при генерации файла-ключа** (Ruling 2, `crypto.py L40`) не выводится: ключ 3. **Warning-лог при генерации файла-ключа** (Ruling 2, `crypto.py L40`) не выводится: ключ
разрешается синхронно в `AddDealSecurity` до построения контейнера, где `ILogger` недоступен, разрешается синхронно в `AddDealSecurity` до построения контейнера, где `ILogger` недоступен,
а провайдер сознательно не регистрируется в DI. Упрощение осознанное; при появлении а провайдер сознательно не регистрируется в DI. Упрощение осознанное; при появлении
потребителя-сервиса warning можно добавить (провайдер остаётся вне DI). потребителя-сервиса warning можно добавить (провайдер остаётся вне DI).
4. **Дополнительный тест** `Decrypt_TokenEncryptedWithAnotherKey_ReturnsEmptyString` добавлен сверх 4. **Дополнительный тест** `Decrypt_TokenEncryptedWithAnotherKey_ReturnsEmptyString` добавлен сверх
списка ТЗ (покрывает ветку «зашифровано чужим ключом» из Ruling 2 — тот же путь, что списка ТЗ (покрывает ветку «зашифровано чужим ключом» из Ruling 2 — тот же путь, что
повреждённый tag). повреждённый tag).
@@ -1,198 +1,198 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 10 curl-приёмка /api/admin/check-message на :5080 (план Task 10 L388-391; Ruling 4/8; # Task 10 curl-приёмка /api/admin/check-message на :5080 (план Task 10 L388-391; Ruling 4/8;
# dashboard_routes.py L267-284, api-map §4.10 L364). Сценарий: 401 без куки → login → # dashboard_routes.py L267-284, api-map §4.10 L364). Сценарий: 401 без куки → login →
# дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → stage1.pass:true, # дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → stage1.pass:true,
# stage2.skipped:true, passed:true → пустой текст → stage1.pass:false «короче 24 символов» → # stage2.skipped:true, passed:true → пустой текст → stage1.pass:false «короче 24 символов» →
# PATCH stopPhrases=[«взаимный пиар»], minLen=10 → текст со стоп-фразой → stage1.pass:false # PATCH stopPhrases=[«взаимный пиар»], minLen=10 → текст со стоп-фразой → stage1.pass:false
# «стоп-фраза «взаимный пиар»» → текст «Ищу работу python…» → stage1.pass:false # «стоп-фраза «взаимный пиар»» → текст «Ищу работу python…» → stage1.pass:false
# «резюме соискателя» (маркер «ищу работу»; stopPhrases уже не содержит «ищу работу») → # «резюме соискателя» (маркер «ищу работу»; stopPhrases уже не содержит «ищу работу») →
# валидный текст → passed:true → logout → 401. Вывод всех шагов в stdout. # валидный текст → passed:true → logout → 401. Вывод всех шагов в stdout.
set -u set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
GOOD_BODY="$SCRIPT_DIR/task-10-good.json" GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
STOP_BODY="$SCRIPT_DIR/task-10-stop.json" STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
RESUME_BODY="$SCRIPT_DIR/task-10-resume.json" RESUME_BODY="$SCRIPT_DIR/task-10-resume.json"
PATCH_BODY="$SCRIPT_DIR/task-10-patch.json" PATCH_BODY="$SCRIPT_DIR/task-10-patch.json"
JAR="/tmp/task10-jar.txt" JAR="/tmp/task10-jar.txt"
OUT="/tmp/task10-out.txt" OUT="/tmp/task10-out.txt"
LOG="/tmp/task10-api.log" LOG="/tmp/task10-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. POST /api/admin/check-message без куки — ожидаем 401 ==" echo "== 1. POST /api/admin/check-message без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. Дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → passed:true ==" echo "== 3. Дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1.pass:true, reason:null" '"stage1":{"pass":true,"reason":null}' check "stage1.pass:true, reason:null" '"stage1":{"pass":true,"reason":null}'
check "stage2 skipped:true pass:true (ИИ-фильтр этапа 2 = skipped, Ruling 4/8)" '"stage2":{"pass":true,"reason":null,"skipped":true}' check "stage2 skipped:true pass:true (ИИ-фильтр этапа 2 = skipped, Ruling 4/8)" '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true' check "passed:true" '"passed":true'
echo echo
echo "== 4. Пустой текст (дефолт minLen=24) → stage1.pass:false «короче 24 символов» ==" echo "== 4. Пустой текст (дефолт minLen=24) → stage1.pass:false «короче 24 символов» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" -d '{"text":""}' > "$OUT" -H "Content-Type: application/json" -d '{"text":""}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1 fail по длине" '"stage1":{"pass":false,"reason":"короче 24 символов"}' check "stage1 fail по длине" '"stage1":{"pass":false,"reason":"короче 24 символов"}'
check "stage2 pass:false skipped:true" '"stage2":{"pass":false,"reason":null,"skipped":true}' check "stage2 pass:false skipped:true" '"stage2":{"pass":false,"reason":null,"skipped":true}'
check "passed:false" '"passed":false' check "passed:false" '"passed":false'
echo echo
echo "== 5. PATCH обработки: stopPhrases=[«взаимный пиар»], minLen=10 ==" echo "== 5. PATCH обработки: stopPhrases=[«взаимный пиар»], minLen=10 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' '"stopPhrases":["взаимный пиар"]' '"minLen":10' check "PATCH 200" '[HTTP:200]' '"stopPhrases":["взаимный пиар"]' '"minLen":10'
echo echo
echo "== 6. Текст со стоп-фразой «взаимный пиар» → stage1.pass:false, причина = фраза ==" echo "== 6. Текст со стоп-фразой «взаимный пиар» → stage1.pass:false, причина = фраза =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1 fail по стоп-фразе" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"' check "stage1 fail по стоп-фразе" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
check "passed:false" '"passed":false' check "passed:false" '"passed":false'
echo echo
echo "== 7. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → резюме ==" echo "== 7. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → резюме =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$RESUME_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$RESUME_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1 fail по резюме (маркер «ищу работу»)" '"pass":false,"reason":"резюме соискателя («ищу работу»)"' check "stage1 fail по резюме (маркер «ищу работу»)" '"pass":false,"reason":"резюме соискателя («ищу работу»)"'
check "passed:false" '"passed":false' check "passed:false" '"passed":false'
echo echo
echo "== 8. Валидный текст (после PATCH) → passed:true ==" echo "== 8. Валидный текст (после PATCH) → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1.pass:true" '"stage1":{"pass":true,"reason":null}' check "stage1.pass:true" '"stage1":{"pass":true,"reason":null}'
check "stage2 skipped pass:true" '"stage2":{"pass":true,"reason":null,"skipped":true}' check "stage2 skipped pass:true" '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true' check "passed:true" '"passed":true'
echo echo
echo "== 9. POST /api/auth/logout, затем check-message — 401 ==" echo "== 9. POST /api/auth/logout, затем check-message — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "после logout check-message 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout check-message 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 10. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 10. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1 +1 @@
{"text": "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу"} {"text": "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу"}
@@ -1 +1 @@
{"stopPhrases":["взаимный пиар"],"minLen":10} {"stopPhrases":["взаимный пиар"],"minLen":10}
@@ -1,55 +1,55 @@
# Task 10 — «Тестер фильтров — этап-1 правила и POST /api/admin/check-message» — отчёт # Task 10 — «Тестер фильтров — этап-1 правила и POST /api/admin/check-message» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 175/175 PASS (было 161, добавлено 14: `IncomingRulesTests`); curl-приёмка :5080 — PASS=25 FAIL=0 (скрипт `task-10-curl-acceptance.sh`, лог `task-10-curl-acceptance.log`). Статус: **complete**. Build 0 warnings / 0 errors; тесты 175/175 PASS (было 161, добавлено 14: `IncomingRulesTests`); curl-приёмка :5080 — PASS=25 FAIL=0 (скрипт `task-10-curl-acceptance.sh`, лог `task-10-curl-acceptance.log`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 10 L367391, Ruling 4 L7175, Ruling 8 L97105; референс `pipeline.py` stage1_plain L94124 + `_resume_reason` L644658, `dashboard_routes.py` L267284, api-map §3.2 L109/§4.10 L364; фронт `SettingsView.vue` L142155/тестер-блок L12451272, `store.js` checkIncomingMessage L17221733). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 10 L367391, Ruling 4 L7175, Ruling 8 L97105; референс `pipeline.py` stage1_plain L94124 + `_resume_reason` L644658, `dashboard_routes.py` L267284, api-map §3.2 L109/§4.10 L364; фронт `SettingsView.vue` L142155/тестер-блок L12451272, `store.js` checkIncomingMessage L17221733).
## Файлы ## Файлы
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `Deal.Modules.Settings/Application/IncomingRules.cs` | create | Этап-1 правила (чистая реализация `stage1_plain` поверх `ISettingsStore`): `CheckAsync(text, ct)` читает переопределения одним `GetAllAsync` и считает вердикт чистой функцией над снимком «дефолты + сохранённые» (Ruling 1). Порядок и причины 1:1: длина (`minLen`) → стоп-фразы (`stopPhrases`, подстрочное вхождение casefold) → резюме (`blockResumes`+`resumeMarkers`, guard слова «резюме») → тип (`wantedType`+`hireMarkers`). Константы kind: length/stop/resume/type (на проходе ""), Stage=1. Повреждённые строки KV — мягкий дефолт (как SettingsService/RatesService); int("24")-строки читаются (семантика `int(x or default)` python), JSON-строка в списке маркеров — `[s]` (`isinstance` python L620623). | | `Deal.Modules.Settings/Application/IncomingRules.cs` | create | Этап-1 правила (чистая реализация `stage1_plain` поверх `ISettingsStore`): `CheckAsync(text, ct)` читает переопределения одним `GetAllAsync` и считает вердикт чистой функцией над снимком «дефолты + сохранённые» (Ruling 1). Порядок и причины 1:1: длина (`minLen`) → стоп-фразы (`stopPhrases`, подстрочное вхождение casefold) → резюме (`blockResumes`+`resumeMarkers`, guard слова «резюме») → тип (`wantedType`+`hireMarkers`). Константы kind: length/stop/resume/type (на проходе ""), Stage=1. Повреждённые строки KV — мягкий дефолт (как SettingsService/RatesService); int("24")-строки читаются (семантика `int(x or default)` python), JSON-строка в списке маркеров — `[s]` (`isinstance` python L620623). |
| `Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs` | create | Результат `{pass, reason, stage, kind, kw}` 1:1 pipeline.py L9799; kind/kw — для тестера-мониторинга и этапа 4 (Ruling 8), наружу в /admin/check-message НЕ идут. | | `Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs` | create | Результат `{pass, reason, stage, kind, kw}` 1:1 pipeline.py L9799; kind/kw — для тестера-мониторинга и этапа 4 (Ruling 8), наружу в /admin/check-message НЕ идут. |
| `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddSettingsModule()`: +`AddScoped<IncomingRules>()` (scoped — ISettingsStore на TenantDbContext). | | `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddSettingsModule()`: +`AddScoped<IncomingRules>()` (scoped — ISettingsStore на TenantDbContext). |
| `Deal.Api/Endpoints/FilterTesterEndpoints.cs` | create | `MapFilterTesterEndpoints`: POST `/api/admin/check-message` (тег settings). 401-гейт {detail}, резолв IncomingRules через RequestServices после гейта. Ответ 1:1 `dashboard_routes.py` L273284/§4.10: `{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}`; этап-1 не прошёл → `stage2={pass:false,reason:null,skipped:true}, passed:false`, иначе `stage2={pass:true,reason:null,skipped:true}, passed:true` (ИИ-фильтр этапа 2 на этапе 2 всегда skipped, Ruling 4/8; реальный ИИ — этап 6). | | `Deal.Api/Endpoints/FilterTesterEndpoints.cs` | create | `MapFilterTesterEndpoints`: POST `/api/admin/check-message` (тег settings). 401-гейт {detail}, резолв IncomingRules через RequestServices после гейта. Ответ 1:1 `dashboard_routes.py` L273284/§4.10: `{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}`; этап-1 не прошёл → `stage2={pass:false,reason:null,skipped:true}, passed:false`, иначе `stage2={pass:true,reason:null,skipped:true}, passed:true` (ИИ-фильтр этапа 2 на этапе 2 всегда skipped, Ruling 4/8; реальный ИИ — этап 6). |
| `Deal.Api/Endpoints/CheckMessageRequest.cs` | create | Тело POST `{text}` (1:1 `CheckMessageBody`, dashboard_routes.py L7273). | | `Deal.Api/Endpoints/CheckMessageRequest.cs` | create | Тело POST `{text}` (1:1 `CheckMessageBody`, dashboard_routes.py L7273). |
| `Deal.Api/Program.cs` | modify | `app.MapFilterTesterEndpoints()`. | | `Deal.Api/Program.cs` | modify | `app.MapFilterTesterEndpoints()`. |
| `tests/…/IncomingRulesTests.cs` | test | +14 тестов (см. ниже). | | `tests/…/IncomingRulesTests.cs` | test | +14 тестов (см. ниже). |
| `.superpowers/sdd/deal-stage2-settings/task-10-curl-acceptance.sh`/`.log` (+4 UTF-8 body-json) | sh/log | curl-приёмка (25 проверок). | | `.superpowers/sdd/deal-stage2-settings/task-10-curl-acceptance.sh`/`.log` (+4 UTF-8 body-json) | sh/log | curl-приёмка (25 проверок). |
## Границы и решения ## Границы и решения
- **kind/kw — только внутри IncomingRules**: план Task 10 L375 требует результат `{pass, reason, stage, kind, kw}`, но wire тестера (api-map §4.10 L364, dashboard_routes L273284) и фронт (`SettingsView.vue` тестер-блок) читают только `stage1.{pass,reason}/stage2.{pass,reason,skipped}/passed`. Поэтому эндпоинт отдаёт ровно форму прототипа; kind («какое правило») и kw (фраза/маркер) возвращает `IncomingRulesResult` — их проверяют unit-тесты, а curl-приёмка видит их в причинах («стоп-фраза «…»», «резюме соискателя («ищу работу»)»). Приёмка плана «kind:resume/stop» в curl выполняется через причины + unit-уровень. - **kind/kw — только внутри IncomingRules**: план Task 10 L375 требует результат `{pass, reason, stage, kind, kw}`, но wire тестера (api-map §4.10 L364, dashboard_routes L273284) и фронт (`SettingsView.vue` тестер-блок) читают только `stage1.{pass,reason}/stage2.{pass,reason,skipped}/passed`. Поэтому эндпоинт отдаёт ровно форму прототипа; kind («какое правило») и kw (фраза/маркер) возвращает `IncomingRulesResult` — их проверяют unit-тесты, а curl-приёмка видит их в причинах («стоп-фраза «…»», «резюме соискателя («ищу работу»)»). Приёмка плана «kind:resume/stop» в curl выполняется через причины + unit-уровень.
- **Дефолтные стоп-фразы содержат «резюме» и «ищу работу»** (constants.py L55) — стоп-проверка идёт ДО resume-проверки (pipeline.py L106108), поэтому resume/type-ветки в тестах и curl достигаются после переопределения `stopPhrases` непересекающимся списком (ровно как пользователь в «Обработке сообщений»). Текст «Ищу работу python» (16 симв.) с дефолтами падает на длине (minLen=24); сценарий плана — после PATCH minLen=10. - **Дефолтные стоп-фразы содержат «резюме» и «ищу работу»** (constants.py L55) — стоп-проверка идёт ДО resume-проверки (pipeline.py L106108), поэтому resume/type-ветки в тестах и curl достигаются после переопределения `stopPhrases` непересекающимся списком (ровно как пользователь в «Обработке сообщений»). Текст «Ищу работу python» (16 симв.) с дефолтами падает на длине (minLen=24); сценарий плана — после PATCH minLen=10.
- **Guard «резюме»** — 1:1 `_resume_reason` L644–658: маркер «резюме» с hire-маркером ДО него в тексте («…вакансия…, присылайте резюме») не режется. Python итерирует маркеры как set (произвольный порядок) — в C# итерация по порядку списка настроек (детерминированно); kw возвращается нормализованным (trim+lowercase, как set-значение python). - **Guard «резюме»** — 1:1 `_resume_reason` L644–658: маркер «резюме» с hire-маркером ДО него в тексте («…вакансия…, присылайте резюме») не режется. Python итерирует маркеры как set (произвольный порядок) — в C# итерация по порядку списка настроек (детерминированно); kw возвращается нормализованным (trim+lowercase, как set-значение python).
- **Порядок правил фиксирован** (длина → стоп → резюме → тип), причины — фиксированные строки python; на проходе `kind/kw=""`, `reason=null`. - **Порядок правил фиксирован** (длина → стоп → резюме → тип), причины — фиксированные строки python; на проходе `kind/kw=""`, `reason=null`.
- **ИИ-фильтр тестера не вызывается** (план Task 10 L377–380, требование задачи п.5): этап 6 — вне этапа; ветка «ошибка ИИ → skipped pass:true» прототипа (L281) здесь не нужна — ИИ не зовётся вовсе, поэтому «успех этапа 2» = `{pass:true, reason:null, skipped:true}` всегда. - **ИИ-фильтр тестера не вызывается** (план Task 10 L377–380, требование задачи п.5): этап 6 — вне этапа; ветка «ошибка ИИ → skipped pass:true» прототипа (L281) здесь не нужна — ИИ не зовётся вовсе, поэтому «успех этапа 2» = `{pass:true, reason:null, skipped:true}` всегда.
## Тесты (14 новых; всего 175 PASS) ## Тесты (14 новых; всего 175 PASS)
`IncomingRulesTests`: короткий текст → kind=length «короче 24 символов»; пустой/пробельный текст → length; minLen из настроек (10) пропускает текст между дефолтом и оверрайдом; чистый длинный текст с дефолтами → pass (kind/kw ""); стоп-фраза из настроек → kind=stop, kw=фраза, причина «стоп-фраза «…»»; регистронезависимое совпадение стоп-фразы (kw сохраняет регистр как в настройках); резюме blockResumes=вкл → kind=resume kw=«ищу работу»; blockResumes=выкл → pass; маркер «резюме» без hire-маркера до → режется; guard «…вакансия… присылайте резюме» → pass; wantedType=freelance с вакансионным текстом → kind=type (причина freelance); wantedType=vacancy с разовым заказом → kind=type (причина vacancy); wantedType=freelance с заказом → pass; wantedType=both (дефолт) с вакансией → pass. `IncomingRulesTests`: короткий текст → kind=length «короче 24 символов»; пустой/пробельный текст → length; minLen из настроек (10) пропускает текст между дефолтом и оверрайдом; чистый длинный текст с дефолтами → pass (kind/kw ""); стоп-фраза из настроек → kind=stop, kw=фраза, причина «стоп-фраза «…»»; регистронезависимое совпадение стоп-фразы (kw сохраняет регистр как в настройках); резюме blockResumes=вкл → kind=resume kw=«ищу работу»; blockResumes=выкл → pass; маркер «резюме» без hire-маркера до → режется; guard «…вакансия… присылайте резюме» → pass; wantedType=freelance с вакансионным текстом → kind=type (причина freelance); wantedType=vacancy с разовым заказом → kind=type (причина vacancy); wantedType=freelance с заказом → pass; wantedType=both (дефолт) с вакансией → pass.
## Приёмка (curl :5080, admin/admin) ## Приёмка (curl :5080, admin/admin)
1. check-message без куки → 401 `{"detail":"Требуется авторизация"}`. 1. check-message без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → дефолты: «Заработок на крипте…» (≥24, без стоп-фраз) → `{"stage1":{"pass":true,"reason":null},"stage2":{"pass":true,"reason":null,"skipped":true},"passed":true}` (форма §4.10 1:1). 2. login → дефолты: «Заработок на крипте…» (≥24, без стоп-фраз) → `{"stage1":{"pass":true,"reason":null},"stage2":{"pass":true,"reason":null,"skipped":true},"passed":true}` (форма §4.10 1:1).
3. Пустой текст → 200 `stage1.pass:false, reason:"короче 24 символов"`, `stage2.pass:false skipped:true`, `passed:false`. 3. Пустой текст → 200 `stage1.pass:false, reason:"короче 24 символов"`, `stage2.pass:false skipped:true`, `passed:false`.
4. PATCH `{"stopPhrases":["взаимный пиар"],"minLen":10}` → снимок `"stopPhrases":["взаимный пиар"],"minLen":10`. 4. PATCH `{"stopPhrases":["взаимный пиар"],"minLen":10}` → снимок `"stopPhrases":["взаимный пиар"],"minLen":10`.
5. Текст со «взаимный пиар» → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false`. 5. Текст со «взаимный пиар» → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false`.
6. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → `reason:"резюме соискателя («ищу работу»)"`, `passed:false`. 6. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → `reason:"резюме соискателя («ищу работу»)"`, `passed:false`.
7. Валидный текст после PATCH → `passed:true`. 7. Валидный текст после PATCH → `passed:true`.
8. logout → check-message 401. Итог: **PASS=25 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен). 8. logout → check-message 401. Итог: **PASS=25 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания ## Concerns / замечания
1. **kind не в wire**: для наблюдаемости «какого правила сработало» на этапе 4 у IncomingRules есть kind/kw; если позже понадобится показывать kind в тестере — это будет расширение контракта §4.10 (сейчас 1:1 с прототипом, kind/kw не отдаём). 1. **kind не в wire**: для наблюдаемости «какого правила сработало» на этапе 4 у IncomingRules есть kind/kw; если позже понадобится показывать kind в тестере — это будет расширение контракта §4.10 (сейчас 1:1 с прототипом, kind/kw не отдаём).
2. **«Резюме»-семантика зависит от настроек пользователя**: пока в `stopPhrases` лежит «резюме»/«ищу работу» (дефолты), такие тексты режутся стоп-списком раньше resume-ветки — это поведение прототипа 1:1 (порядок правил L106–108), не баг. 2. **«Резюме»-семантика зависит от настроек пользователя**: пока в `stopPhrases` лежит «резюме»/«ищу работу» (дефолты), такие тексты режутся стоп-списком раньше resume-ветки — это поведение прототипа 1:1 (порядок правил L106–108), не баг.
3. **Python-`casefold` ≈ .NET `ToLowerInvariant`** для кириллицы/латиницы; экзотика (`ß`→ss) не воспроизводится — для RU/EN-текстов каналов незначимо. 3. **Python-`casefold` ≈ .NET `ToLowerInvariant`** для кириллицы/латиницы; экзотика (`ß`→ss) не воспроизводится — для RU/EN-текстов каналов незначимо.
4. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/wire-ветки эндпоинта покрыты curl-приёмкой; юнит-уровень — IncomingRules (правила) + wire-форма в curl. 4. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/wire-ветки эндпоинта покрыты curl-приёмкой; юнит-уровень — IncomingRules (правила) + wire-форма в curl.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0 dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175 (было 161, +14) dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175 (было 161, +14)
sh task-10-curl-acceptance.sh → PASS=25 FAIL=0 (лог task-10-curl-acceptance.log) sh task-10-curl-acceptance.sh → PASS=25 FAIL=0 (лог task-10-curl-acceptance.log)
``` ```
@@ -1 +1 @@
{"text": "Ищу работу python backend разработчик с опытом 5 лет, удалённая занятость, фриланс"} {"text": "Ищу работу python backend разработчик с опытом 5 лет, удалённая занятость, фриланс"}
@@ -1 +1 @@
{"text": "Заметил у вас отличный сервис и взаимный пиар в чатах, давайте продвигать каналы друг друга бесплатно"} {"text": "Заметил у вас отличный сервис и взаимный пиар в чатах, давайте продвигать каналы друг друга бесплатно"}
@@ -1,296 +1,296 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 11 curl-приёмка этапа 2 «Дейл»: единый сквозной сценарий на :5080 (план Task 11 L393-409, # Task 11 curl-приёмка этапа 2 «Дейл»: единый сквозной сценарий на :5080 (план Task 11 L393-409,
# Self-Review L411-429). Порядок: 401 без куки → login admin/admin → GET /api/settings (дефолты) # Self-Review L411-429). Порядок: 401 без куки → login admin/admin → GET /api/settings (дефолты)
# → PATCH группы Tasks 5/7 (minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/ # → PATCH группы Tasks 5/7 (minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/
# colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource) → GET сверка (секреты # colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource) → GET сверка (секреты
# замаскированы; ratesCache/mlDecisions/aiDecisions не публикуются) → psql (enc: в aiConfigs/tgKeys, # замаскированы; ratesCache/mlDecisions/aiDecisions не публикуются) → psql (enc: в aiConfigs/tgKeys,
# открытого ключа нет) → POST /api/rates/refresh + GET /api/rates (mock-кэш) → # открытого ключа нет) → POST /api/rates/refresh + GET /api/rates (mock-кэш) →
# POST /api/ai/check (ветка по настройкам: локальный ollama) → GET /api/ml/status (ready:false- # POST /api/ai/check (ветка по настройкам: локальный ollama) → GET /api/ml/status (ready:false-
# структура) + predict + reset → POST /api/admin/check-message (валидный → passed:true; текст со # структура) + predict + reset → POST /api/admin/check-message (валидный → passed:true; текст со
# стоп-фразой → отсев) → logout → 401 на GET /api/settings → очистка dev-БД. PASS/FAIL каждого шага. # стоп-фразой → отсев) → logout → 401 на GET /api/settings → очистка dev-БД. PASS/FAIL каждого шага.
set -u set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
PATCH_BODY="$SCRIPT_DIR/task-11-patch.json" PATCH_BODY="$SCRIPT_DIR/task-11-patch.json"
GOOD_BODY="$SCRIPT_DIR/task-10-good.json" GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
STOP_BODY="$SCRIPT_DIR/task-10-stop.json" STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json" PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
JAR="/tmp/task11-jar.txt" JAR="/tmp/task11-jar.txt"
OUT="/tmp/task11-out.txt" OUT="/tmp/task11-out.txt"
LOG="/tmp/task11-api.log" LOG="/tmp/task11-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
echo " ----- полный ответ -----" echo " ----- полный ответ -----"
cat "$OUT" cat "$OUT"
echo " ------------------------" echo " ------------------------"
fi fi
} }
check_absent() { check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе # $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1 desc=$1
pat=$2 pat=$2
if grep -qF -- "$pat" "$OUT"; then if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не должно присутствовать: $pat" echo " [FAIL] $desc — не должно присутствовать: $pat"
echo " ----- полный ответ -----" echo " ----- полный ответ -----"
cat "$OUT" cat "$OUT"
echo " ------------------------" echo " ------------------------"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pat)" echo " [PASS] $desc (отсутствует: $pat)"
fi fi
} }
show() { show() {
# Короткий превью ответа в лог (полный ответ — в $OUT, печатается при FAIL) # Короткий превью ответа в лог (полный ответ — в $OUT, печатается при FAIL)
head -c 200 "$OUT" head -c 200 "$OUT"
echo " …" echo " …"
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1 $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null) ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null)
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. GET /api/settings без куки — ожидаем 401 ==" echo "== 1. GET /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
show show
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. GET /api/settings — дефолтный снимок (чистая БД) ==" echo "== 3. GET /api/settings — дефолтный снимок (чистая БД) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show show
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "int/bool-дефолты" '"minLen":24' '"archiveAfterDays":14' '"mlEnabled":true' '"aiEnabled":true' check "int/bool-дефолты" '"minLen":24' '"archiveAfterDays":14' '"mlEnabled":true' '"aiEnabled":true'
check "дефолтные stopPhrases (4)" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]' check "дефолтные stopPhrases (4)" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
check "строковые дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"' check "строковые дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
check "colState пуст, tgKeys пусты" '"colState":{}' '"tgKeys":{"apiId":"","apiHashSet":false}' check "colState пуст, tgKeys пусты" '"colState":{}' '"tgKeys":{"apiId":"","apiHashSet":false}'
check "aiConfigs deepseek без ключа (маска)" '"deepseek":{"baseUrl":"https://api.deepseek.com","model":"deepseek-v4-flash","keySet":false,"keyMasked":""}' check "aiConfigs deepseek без ключа (маска)" '"deepseek":{"baseUrl":"https://api.deepseek.com","model":"deepseek-v4-flash","keySet":false,"keyMasked":""}'
PROV_COUNT=$(grep -o '"id":"' "$OUT" | wc -l) PROV_COUNT=$(grep -o '"id":"' "$OUT" | wc -l)
if [ "$PROV_COUNT" = "7" ]; then if [ "$PROV_COUNT" = "7" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] providers — 7 провайдеров ($PROV_COUNT)" echo " [PASS] providers — 7 провайдеров ($PROV_COUNT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] providers — ожидалось 7, найдено $PROV_COUNT" echo " [FAIL] providers — ожидалось 7, найдено $PROV_COUNT"
fi fi
check_absent "в дефолтном снимке нет шифротекста enc:" 'enc:' check_absent "в дефолтном снимке нет шифротекста enc:" 'enc:'
check_absent "внутренние ключи не публикуются" 'ratesCache' check_absent "внутренние ключи не публикуются" 'ratesCache'
check_absent "внутренние ключи не публикуются" 'mlDecisions' check_absent "внутренние ключи не публикуются" 'mlDecisions'
check_absent "внутренние ключи не публикуются" 'aiDecisions' check_absent "внутренние ключи не публикуются" 'aiDecisions'
echo echo
echo "== 4. PATCH-группа: minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource ==" echo "== 4. PATCH-группа: minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT"
show show
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "обработка: minLen/stopPhrases" '"minLen":30' '"stopPhrases":["взаимный пиар"]' check "обработка: minLen/stopPhrases" '"minLen":30' '"stopPhrases":["взаимный пиар"]'
check "хранение/уведомления" '"autoArchive":false' '"archiveAfterDays":7' '"remindersEnabled":false' check "хранение/уведомления" '"autoArchive":false' '"archiveAfterDays":7' '"remindersEnabled":false'
check "colState passthrough" '"colState":{"review":1,"done":2}' check "colState passthrough" '"colState":{"review":1,"done":2}'
check "валюта: rateSource mock" '"rateSource":"mock"' check "валюта: rateSource mock" '"rateSource":"mock"'
check "ИИ: активный провайдер ollama" '"aiProvider":"ollama"' check "ИИ: активный провайдер ollama" '"aiProvider":"ollama"'
check "aiConfigs: deepseek keySet+маска (без открытого ключа)" '"keySet":true,"keyMasked":"sk-1…90ab"' check "aiConfigs: deepseek keySet+маска (без открытого ключа)" '"keySet":true,"keyMasked":"sk-1…90ab"'
check "tgKeys: apiId + apiHashSet" '"tgKeys":{"apiId":"123456","apiHashSet":true}' check "tgKeys: apiId + apiHashSet" '"tgKeys":{"apiId":"123456","apiHashSet":true}'
check "myPrompts: 1 запись с id pp_" '"myPrompts":[{"id":"pp_' check "myPrompts: 1 запись с id pp_" '"myPrompts":[{"id":"pp_'
check_absent "открытого apiKey в ответе PATCH нет" 'sk-1234567890ab' check_absent "открытого apiKey в ответе PATCH нет" 'sk-1234567890ab'
check_absent "enc: в public-снимке нет" 'enc:' check_absent "enc: в public-снимке нет" 'enc:'
echo echo
echo "== 5. GET /api/settings — сверка: переопределения видны, секреты замаскированы ==" echo "== 5. GET /api/settings — сверка: переопределения видны, секреты замаскированы =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show show
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "переопределения применены" '"minLen":30' '"archiveAfterDays":7' '"autoArchive":false' '"remindersEnabled":false' check "переопределения применены" '"minLen":30' '"archiveAfterDays":7' '"autoArchive":false' '"remindersEnabled":false'
check "stopPhrases/colState новые" '"stopPhrases":["взаимный пиар"]' '"colState":{"review":1,"done":2}' check "stopPhrases/colState новые" '"stopPhrases":["взаимный пиар"]' '"colState":{"review":1,"done":2}'
check "rateSource/aiProvider новые" '"rateSource":"mock"' '"aiProvider":"ollama"' check "rateSource/aiProvider новые" '"rateSource":"mock"' '"aiProvider":"ollama"'
check "myPrompts сохранены" '"myPrompts":[{"id":"pp_' check "myPrompts сохранены" '"myPrompts":[{"id":"pp_'
check "deepseek keySet+маска" '"keySet":true,"keyMasked":"sk-1…90ab"' check "deepseek keySet+маска" '"keySet":true,"keyMasked":"sk-1…90ab"'
check "tgKeys apiHashSet" '"apiId":"123456","apiHashSet":true' check "tgKeys apiHashSet" '"apiId":"123456","apiHashSet":true'
check_absent "открытого apiKey в GET нет" 'sk-1234567890ab' check_absent "открытого apiKey в GET нет" 'sk-1234567890ab'
check_absent "enc: в GET-снимке нет (наружу только маски)" 'enc:' check_absent "enc: в GET-снимке нет (наружу только маски)" 'enc:'
check_absent "внутренний ratesCache не публикуется" 'ratesCache' check_absent "внутренний ratesCache не публикуется" 'ratesCache'
check_absent "внутренний mlDecisions не публикуется" 'mlDecisions' check_absent "внутренний mlDecisions не публикуется" 'mlDecisions'
check_absent "внутренний aiDecisions не публикуется" 'aiDecisions' check_absent "внутренний aiDecisions не публикуется" 'aiDecisions'
echo echo
echo "== 6. psql: строки settings созданы; aiConfigs/tgKeys — enc:, без открытого ключа ==" echo "== 6. psql: строки settings созданы; aiConfigs/tgKeys — enc:, без открытого ключа =="
$PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings WHERE \"Key\" IN ('aiConfigs','tgKeys','minLen','stopPhrases','colState','aiProvider','rateSource','myPrompts');" > "$OUT" 2>/dev/null $PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings WHERE \"Key\" IN ('aiConfigs','tgKeys','minLen','stopPhrases','colState','aiProvider','rateSource','myPrompts');" > "$OUT" 2>/dev/null
check "8 ожидаемых ключей-переопределений на месте" '8' check "8 ожидаемых ключей-переопределений на месте" '8'
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='aiConfigs';" > "$OUT" 2>/dev/null $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='aiConfigs';" > "$OUT" 2>/dev/null
check "aiConfigs.ValueJson содержит enc: (ключ зашифрован)" 'enc:' check "aiConfigs.ValueJson содержит enc: (ключ зашифрован)" 'enc:'
check_absent "aiConfigs.ValueJson не содержит открытого ключа" 'sk-1234567890ab' check_absent "aiConfigs.ValueJson не содержит открытого ключа" 'sk-1234567890ab'
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null
check "tgKeys.ValueJson содержит enc: (apiHash зашифрован)" 'enc:' check "tgKeys.ValueJson содержит enc: (apiHash зашифрован)" 'enc:'
echo echo
echo "== 7. POST /api/rates/refresh — ok:true, mock-кэш ==" echo "== 7. POST /api/rates/refresh — ok:true, mock-кэш =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" \
-H "Content-Type: application/json" > "$OUT" -H "Content-Type: application/json" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "refresh 200 ok:true" '[HTTP:200]' '"ok":true' '"source":"mock"' '"base":"RUB"' check "refresh 200 ok:true" '[HTTP:200]' '"ok":true' '"source":"mock"' '"base":"RUB"'
echo echo
echo "== 7b. GET /api/rates — тот же mock-кэш (updatedAt на месте) ==" echo "== 7b. GET /api/rates — тот же mock-кэш (updatedAt на месте) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET rates 200, source mock" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5' check "GET rates 200, source mock" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
check_absent "updatedAt не null после refresh" '"updatedAt":null' check_absent "updatedAt не null после refresh" '"updatedAt":null'
echo echo
echo "== 8. POST /api/ai/check — ветка по настройкам (активный провайдер ollama — локальный) ==" echo "== 8. POST /api/ai/check — ветка по настройкам (активный провайдер ollama — локальный) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" \
-H "Content-Type: application/json" > "$OUT" -H "Content-Type: application/json" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "ai/check 200 ok:true" '[HTTP:200]' '"ok":true' '"provider":"ollama"' '"local":true' '"name":"Ollama (локально)"' check "ai/check 200 ok:true" '[HTTP:200]' '"ok":true' '"provider":"ollama"' '"local":true' '"name":"Ollama (локально)"'
check "сообщение локального сервера" 'Локальный сервер «Ollama (локально)» (ping в проде)' check "сообщение локального сервера" 'Локальный сервер «Ollama (локально)» (ping в проде)'
echo echo
echo "== 9. GET /api/ml/status — ready:false-структура (заглушка LocalMlClient, Ruling 5) ==" echo "== 9. GET /api/ml/status — ready:false-структура (заглушка LocalMlClient, Ruling 5) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "ml/status 200, форма §4.10" '[HTTP:200]' '"enabled":true' '"reachable":true' '"service":{"ready":false' '"outbox":0' '"eval":{"count":0,"correct":0,"accuracy":0}' check "ml/status 200, форма §4.10" '[HTTP:200]' '"enabled":true' '"reachable":true' '"service":{"ready":false' '"outbox":0' '"eval":{"count":0,"correct":0,"accuracy":0}'
echo echo
echo "== 9b. POST /api/ml/predict с текстом — «не уверен», все поля 1:1 ==" echo "== 9b. POST /api/ml/predict с текстом — «не уверен», все поля 1:1 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "predict 200" '[HTTP:200]' check "predict 200" '[HTTP:200]'
check "take:false/label:null/scores:{}" '"take":false' '"label":null' '"scores":{}' check "take:false/label:null/scores:{}" '"take":false' '"label":null' '"scores":{}'
check "ready:false/terms:[]/type:null" '"ready":false' '"terms":[]' '"type":null' check "ready:false/terms:[]/type:null" '"ready":false' '"terms":[]' '"type":null'
echo echo
echo "== 9c. POST /api/ml/reset — {ok:true} ==" echo "== 9c. POST /api/ml/reset — {ok:true} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" \
-H "Content-Type: application/json" > "$OUT" -H "Content-Type: application/json" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "ml/reset 200 ok:true" '[HTTP:200]' '{"ok":true}' check "ml/reset 200 ok:true" '[HTTP:200]' '{"ok":true}'
echo echo
echo "== 10. POST /api/admin/check-message: валидный текст → passed:true ==" echo "== 10. POST /api/admin/check-message: валидный текст → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1.pass:true / stage2 skipped pass:true" '"stage1":{"pass":true,"reason":null}' '"stage2":{"pass":true,"reason":null,"skipped":true}' check "stage1.pass:true / stage2 skipped pass:true" '"stage1":{"pass":true,"reason":null}' '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true' check "passed:true" '"passed":true'
echo echo
echo "== 10b. POST /api/admin/check-message: текст со стоп-фразой «взаимный пиар» → отсев ==" echo "== 10b. POST /api/admin/check-message: текст со стоп-фразой «взаимный пиар» → отсев =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "stage1 fail по стоп-фразе из настроек" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"' check "stage1 fail по стоп-фразе из настроек" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
check "stage2 skipped, passed:false" '"stage2":{"pass":false,"reason":null,"skipped":true}' '"passed":false' check "stage2 skipped, passed:false" '"stage2":{"pass":false,"reason":null,"skipped":true}' '"passed":false'
echo echo
echo "== 11. logout → GET /api/settings — 401 ==" echo "== 11. logout → GET /api/settings — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show show
check "после logout settings 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout settings 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1 $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null) ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null)
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки сквозной curl-приёмки этапа 2 прошли" echo " [PASS] все проверки сквозной curl-приёмки этапа 2 прошли"
@@ -1 +1 @@
{"minLen":30,"archiveAfterDays":7,"autoArchive":false,"remindersEnabled":false,"stopPhrases":["взаимный пиар"],"colState":{"review":1,"done":2},"aiProvider":"ollama","aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}},"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"},"myPrompts":[{"name":"Тестер","description":"проверка","prompt":"Ты — помощник оператора"}],"rateSource":"mock"} {"minLen":30,"archiveAfterDays":7,"autoArchive":false,"remindersEnabled":false,"stopPhrases":["взаимный пиар"],"colState":{"review":1,"done":2},"aiProvider":"ollama","aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}},"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"},"myPrompts":[{"name":"Тестер","description":"проверка","prompt":"Ты — помощник оператора"}],"rateSource":"mock"}
@@ -1,87 +1,87 @@
# Task 11 — «Финал этапа — интеграция и сквозная приёмка» — отчёт # Task 11 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
Статус: **complete (review pending)**. Build 0 warnings / 0 errors; unit-тесты 175/175 PASS; Статус: **complete (review pending)**. Build 0 warnings / 0 errors; unit-тесты 175/175 PASS;
сквозная curl-приёмка :5080 — **PASS=60 FAIL=0** (один сценарий: `task-11-curl-acceptance.sh`, сквозная curl-приёмка :5080 — **PASS=60 FAIL=0** (один сценарий: `task-11-curl-acceptance.sh`,
лог `task-11-curl-acceptance.log`). Код/конфиги (кроме доков и ledger) не менялись. лог `task-11-curl-acceptance.log`). Код/конфиги (кроме доков и ledger) не менялись.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 11 L393409, Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 11 L393409,
Self-Review L411429). Self-Review L411429).
## Сквозной сценарий приёмки (один прогон, dev-БД очищается до/после) ## Сквозной сценарий приёмки (один прогон, dev-БД очищается до/после)
| Шаг | Проверка | Результат | | Шаг | Проверка | Результат |
|---|---|---| |---|---|---|
| 0 | psql: таблица `settings` дефолтного тенанта пуста; старт Deal.Api :5080 (Development) | PASS | | 0 | psql: таблица `settings` дефолтного тенанта пуста; старт Deal.Api :5080 (Development) | PASS |
| 1 | `GET /api/settings` без куки → 401 `{"detail":"Требуется авторизация"}` | PASS | | 1 | `GET /api/settings` без куки → 401 `{"detail":"Требуется авторизация"}` | PASS |
| 2 | `POST /api/auth/login` admin/admin → `{ok:true, login:admin}` | PASS | | 2 | `POST /api/auth/login` admin/admin → `{ok:true, login:admin}` | PASS |
| 3 | `GET /api/settings` (чистая БД): дефолты — `minLen:24`, `archiveAfterDays:14`, `stopPhrases` 4 деф., `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`, `tgKeys:{apiId:"",apiHashSet:false}`, `colState:{}`, `aiConfigs` без ключей (маски `keyMasked:""`), `providers` — 7; нет `enc:`, нет `ratesCache`/`mlDecisions`/`aiDecisions` | PASS (11 проверок) | | 3 | `GET /api/settings` (чистая БД): дефолты — `minLen:24`, `archiveAfterDays:14`, `stopPhrases` 4 деф., `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`, `tgKeys:{apiId:"",apiHashSet:false}`, `colState:{}`, `aiConfigs` без ключей (маски `keyMasked:""`), `providers` — 7; нет `enc:`, нет `ratesCache`/`mlDecisions`/`aiDecisions` | PASS (11 проверок) |
| 4 | `PATCH /api/settings` группой Tasks 5/7 (разные типы: `minLen` int, `archiveAfterDays` int, `autoArchive`/`remindersEnabled` bool, `stopPhrases` list, `colState` dict, `aiProvider` string, `aiConfigs.deepseek.apiKey` секрет, `tgKeys` секрет+apiId, `myPrompts`, `rateSource`) → снимок: значения применены, `keySet:true,keyMasked:"sk-1…90ab"`, `apiHashSet:true`, `myPrompts[0].id` = `pp_…`, открытого ключа и `enc:` в ответе нет | PASS (11 проверок) | | 4 | `PATCH /api/settings` группой Tasks 5/7 (разные типы: `minLen` int, `archiveAfterDays` int, `autoArchive`/`remindersEnabled` bool, `stopPhrases` list, `colState` dict, `aiProvider` string, `aiConfigs.deepseek.apiKey` секрет, `tgKeys` секрет+apiId, `myPrompts`, `rateSource`) → снимок: значения применены, `keySet:true,keyMasked:"sk-1…90ab"`, `apiHashSet:true`, `myPrompts[0].id` = `pp_…`, открытого ключа и `enc:` в ответе нет | PASS (11 проверок) |
| 5 | `GET /api/settings` сверка: переопределения на месте, секреты замаскированы, `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются, `enc:`/открытый ключ отсутствуют | PASS (12 проверок) | | 5 | `GET /api/settings` сверка: переопределения на месте, секреты замаскированы, `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются, `enc:`/открытый ключ отсутствуют | PASS (12 проверок) |
| 6 | psql: 8 ключей-переопределений созданы; `aiConfigs.ValueJson` и `tgKeys.ValueJson` содержат `enc:`, открытого ключа в БД нет | PASS (4 проверки) | | 6 | psql: 8 ключей-переопределений созданы; `aiConfigs.ValueJson` и `tgKeys.ValueJson` содержат `enc:`, открытого ключа в БД нет | PASS (4 проверки) |
| 7 | `POST /api/rates/refresh``{ok:true, rates:{base:"RUB",…,source:"mock",updatedAt:<ms>}}`; затем `GET /api/rates` — тот же кэш (source mock, `updatedAt` не null) | PASS (3 проверки) | | 7 | `POST /api/rates/refresh``{ok:true, rates:{base:"RUB",…,source:"mock",updatedAt:<ms>}}`; затем `GET /api/rates` — тот же кэш (source mock, `updatedAt` не null) | PASS (3 проверки) |
| 8 | `POST /api/ai/check` (без тела — ветка по настройкам: активный `aiProvider:"ollama"`) → `ok:true`, «Локальный сервер «Ollama (локально)» (ping в проде)», `local:true` | PASS (2 проверки) | | 8 | `POST /api/ai/check` (без тела — ветка по настройкам: активный `aiProvider:"ollama"`) → `ok:true`, «Локальный сервер «Ollama (локально)» (ping в проде)», `local:true` | PASS (2 проверки) |
| 9 | `GET /api/ml/status` — форма §4.10 (`enabled:true`, `reachable:true`, `service.ready:false`, `outbox:0`, eval обнулён); `POST /api/ml/predict` с текстом → `take:false,label:null,scores:{},ready:false,terms:[],type:null`; `POST /api/ml/reset``{ok:true}` | PASS (5 проверок) | | 9 | `GET /api/ml/status` — форма §4.10 (`enabled:true`, `reachable:true`, `service.ready:false`, `outbox:0`, eval обнулён); `POST /api/ml/predict` с текстом → `take:false,label:null,scores:{},ready:false,terms:[],type:null`; `POST /api/ml/reset``{ok:true}` | PASS (5 проверок) |
| 10 | `POST /api/admin/check-message`: валидный текст → `stage1.pass:true`, `stage2.skipped:true`, `passed:true`; текст со стоп-фразой «взаимный пиар» (из настроек PATCH) → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false` | PASS (6 проверок) | | 10 | `POST /api/admin/check-message`: валидный текст → `stage1.pass:true`, `stage2.skipped:true`, `passed:true`; текст со стоп-фразой «взаимный пиар» (из настроек PATCH) → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false` | PASS (6 проверок) |
| 11 | logout → `GET /api/settings` 401 | PASS | | 11 | logout → `GET /api/settings` 401 | PASS |
| 12 | Очистка dev-БД (0 строк), сервер остановлен, порт 5080 свободен | PASS | | 12 | Очистка dev-БД (0 строк), сервер остановлен, порт 5080 свободен | PASS |
Порядок эндпоинтов совпадает с задачами 5/7→8→6→9→10, т.е. проверены все группы этапа 2 и их Порядок эндпоинтов совпадает с задачами 5/7→8→6→9→10, т.е. проверены все группы этапа 2 и их
связность на одном состоянии БД: настройки из PATCH реально влияют на `/api/ai/check` (ветка по связность на одном состоянии БД: настройки из PATCH реально влияют на `/api/ai/check` (ветка по
`aiProvider`) и `/api/admin/check-message` (`stopPhrases`/`minLen` из настроек), курсы кэшируются `aiProvider`) и `/api/admin/check-message` (`stopPhrases`/`minLen` из настроек), курсы кэшируются
внутренним ключом, который не «протекает» в GET /settings. внутренним ключом, который не «протекает» в GET /settings.
## Сборка и тесты ## Сборка и тесты
``` ```
dotnet build Deal.sln (src/core) → 0 warnings / 0 errors dotnet build Deal.sln (src/core) → 0 warnings / 0 errors
dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175 dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175
sh scripts/build.sh && sh scripts/test.sh → build 0/0; тесты 175 PASS sh scripts/build.sh && sh scripts/test.sh → build 0/0; тесты 175 PASS
``` ```
## Изменения в доках и ledger ## Изменения в доках и ledger
- `docs/technical/Техническая-документация-Дейл.md`: §11 — добавлен блок «Выполнено на этапе 2 - `docs/technical/Техническая-документация-Дейл.md`: §11 — добавлен блок «Выполнено на этапе 2
(2026-09-06)» (модуль Settings: настройки/шифрование/ai-check/rates/ML-заглушка/тестер, 175 PASS) (2026-09-06)» (модуль Settings: настройки/шифрование/ai-check/rates/ML-заглушка/тестер, 175 PASS)
и TODO про оживление Vue-фронта на этапе 3; §13 — заголовок «актуально для этапа 2», intro и TODO про оживление Vue-фронта на этапе 3; §13 — заголовок «актуально для этапа 2», intro
дополнен этапом 2 и кредами; новые подразделы «4a. Шифрование секретов настроек» (env дополнен этапом 2 и кредами; новые подразделы «4a. Шифрование секретов настроек» (env
`DEAL_ENCRYPTION_KEY`, файл `data/encryption.key`, env `DEAL_ENCRYPTION_KEY_FILE`, формат `enc:`, `DEAL_ENCRYPTION_KEY`, файл `data/encryption.key`, env `DEAL_ENCRYPTION_KEY_FILE`, формат `enc:`,
маски) и «4b. Эндпоинты этапа 2» (GET/PATCH `/settings` + таблица `settings` тенанта, `/ai/check`, маски) и «4b. Эндпоинты этапа 2» (GET/PATCH `/settings` + таблица `settings` тенанта, `/ai/check`,
`/rates*`, `/api/ml/*`, `/admin/check-message`); §13.6 — ожидается 175 PASS. `/rates*`, `/api/ml/*`, `/admin/check-message`); §13.6 — ожидается 175 PASS.
- `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: заголовок «на конец этапа 2»; этап 2 внесён - `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: заголовок «на конец этапа 2»; этап 2 внесён
в «Выполнено» (задачи 1–11, 175 PASS, curl 60/60 + psql) с ограничением «фронт полностью оживёт на в «Выполнено» (задачи 1–11, 175 PASS, curl 60/60 + psql) с ограничением «фронт полностью оживёт на
этапе 3»; из «Оставшихся этапов» блок этапа 2 убран (оставшиеся начинаются с этапа 3). этапе 3»; из «Оставшихся этапов» блок этапа 2 убран (оставшиеся начинаются с этапа 3).
- `.superpowers/sdd/deal-stage2-settings/progress.md`: строка «Task 11: complete (review pending). - `.superpowers/sdd/deal-stage2-settings/progress.md`: строка «Task 11: complete (review pending).
Отчёт: task-11-report.md.» + todo `[x]`. Отчёт: task-11-report.md.» + todo `[x]`.
## Артефакты ## Артефакты
- `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.sh` — скрипт сценария (PASS/FAIL каждого шага). - `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.sh` — скрипт сценария (PASS/FAIL каждого шага).
- `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.log` — лог прогона (PASS=60 FAIL=0). - `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.log` — лог прогона (PASS=60 FAIL=0).
- `.superpowers/sdd/deal-stage2-settings/task-11-patch.json` — тело PATCH-группы (переиспользованы - `.superpowers/sdd/deal-stage2-settings/task-11-patch.json` — тело PATCH-группы (переиспользованы
`task-10-good.json`/`task-10-stop.json`/`task-9-predict.json` для текстов). `task-10-good.json`/`task-10-stop.json`/`task-9-predict.json` для текстов).
## Отклонения и замечания ## Отклонения и замечания
1. **Формулировка «resume»-ветки в тестере**: этап-1 правила проверены в Task 10 (unit + curl); 1. **Формулировка «resume»-ветки в тестере**: этап-1 правила проверены в Task 10 (unit + curl);
в сквозном сценарии Task 11 по плану гоняются только «валидный текст» и «текст со стоп-фразой» — в сквозном сценарии Task 11 по плану гоняются только «валидный текст» и «текст со стоп-фразой» —
resume/type-ветки здесь не повторяются (план Task 11 L400 требует именно стоп-фразу и валидный). resume/type-ветки здесь не повторяются (план Task 11 L400 требует именно стоп-фразу и валидный).
2. **`/api/ml/candidates|apply` не повторялись в сквозном сценарии** — они приняты в Task 9 2. **`/api/ml/candidates|apply` не повторялись в сквозном сценарии** — они приняты в Task 9
(curl 31/31); сценарий Task 11 включает status/predict/reset (план L396399). (curl 31/31); сценарий Task 11 включает status/predict/reset (план L396399).
3. **Psql-колонки — PascalCase** (`Key`/`ValueJson`/`UpdatedAt`, конвенция EF): psql-проверка 3. **Psql-колонки — PascalCase** (`Key`/`ValueJson`/`UpdatedAt`, конвенция EF): psql-проверка
использует кавычки `"Key"`/`"ValueJson"` (в первых прогонах сценария snake_case-запросы падали — использует кавычки `"Key"`/`"ValueJson"` (в первых прогонах сценария snake_case-запросы падали —
исправлено в скрипте; финальный прогон 60/60). исправлено в скрипте; финальный прогон 60/60).
4. **ai/check в сценарии детерминирован без внешней сети**: активный провайдер `ollama` (локальный) — 4. **ai/check в сценарии детерминирован без внешней сети**: активный провайдер `ollama` (локальный) —
ветка «по настройкам» без реального HTTP; облачные ветки (401/403/сеть) приняты в Task 6. ветка «по настройкам» без реального HTTP; облачные ветки (401/403/сеть) приняты в Task 6.
5. Известные ограничения этапа зафиксированы в отчёте Task 11 плана и в доке §11: Telegram-вкладка, 5. Известные ограничения этапа зафиксированы в отчёте Task 11 плана и в доке §11: Telegram-вкладка,
«Проверить правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи» «Проверить правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи»
(ai/suggest-keywords), канбан-фронт и полный `boot()` Vue-фронта — этапы 36 (Ruling 8/11); (ai/suggest-keywords), канбан-фронт и полный `boot()` Vue-фронта — этапы 36 (Ruling 8/11);
реальные ml/ai/telegram-сервисы — этап 6. реальные ml/ai/telegram-сервисы — этап 6.
6. Docker из direct-команд терминала недоступен (sandbox), psql внутри sh-скриптов работает — 6. Docker из direct-команд терминала недоступен (sandbox), psql внутри sh-скриптов работает —
приёмка выполнялась скриптом, как и в Tasks 510. приёмка выполнялась скриптом, как и в Tasks 510.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → 0 warnings / 0 errors dotnet build Deal.sln → 0 warnings / 0 errors
dotnet test Deal.sln --no-build → 175 PASS dotnet test Deal.sln --no-build → 175 PASS
sh task-11-curl-acceptance.sh → PASS=60 FAIL=0 (лог task-11-curl-acceptance.log) sh task-11-curl-acceptance.sh → PASS=60 FAIL=0 (лог task-11-curl-acceptance.log)
``` ```
@@ -1,79 +1,79 @@
# Task 2 — «Модуль Settings: каталог ключей, дефолты, DTO, порт хранилища» — отчёт # Task 2 — «Модуль Settings: каталог ключей, дефолты, DTO, порт хранилища» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 51/51 PASS (было 38, добавлено 13 — `SettingsCatalogTests`). Статус: **complete**. Build 0 warnings / 0 errors; тесты 51/51 PASS (было 38, добавлено 13 — `SettingsCatalogTests`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 2 L148184, Ruling 1/3/4/9/10). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 2 L148184, Ruling 1/3/4/9/10).
## Файлы ## Файлы
Все созданы в `src/core/Deal.Modules.Settings/` (модуль остался чистым: без EF/Infrastructure/Npgsql — grep 0 совпадений; без эндпоинтов, без SettingsService, без Registrar — это Tasks 3–5). Изменений в существующих файлах нет (csproj не трогался: новых ссылок модулю не потребовалось). Все созданы в `src/core/Deal.Modules.Settings/` (модуль остался чистым: без EF/Infrastructure/Npgsql — grep 0 совпадений; без эндпоинтов, без SettingsService, без Registrar — это Tasks 3–5). Изменений в существующих файлах нет (csproj не трогался: новых ссылок модулю не потребовалось).
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `Application/SettingKind.cs` | enum | Категории ключей: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal (план L151). | | `Application/SettingKind.cs` | enum | Категории ключей: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal (план L151). |
| `Application/SettingsKeys.cs` | static class | Константы имён ключей (39 публичных + 3 внутренних) + каталог `PublicKeys: ключ → SettingKind` + `FindPublicKind`. | | `Application/SettingsKeys.cs` | static class | Константы имён ключей (39 публичных + 3 внутренних) + каталог `PublicKeys: ключ → SettingKind` + `FindPublicKind`. |
| `Application/SettingsDefaults.cs` | static class | Дефолтные значения (см. ниже). | | `Application/SettingsDefaults.cs` | static class | Дефолтные значения (см. ниже). |
| `Application/DefaultPrompts.cs` | static class | `DefaultAiPrompt`/`DefaultCardPrompt`/`DefaultAiFilterPrompt` — тексты из `data.js`. | | `Application/DefaultPrompts.cs` | static class | `DefaultAiPrompt`/`DefaultCardPrompt`/`DefaultAiFilterPrompt` — тексты из `data.js`. |
| `Application/AiProviderDefinition.cs` | record | Id/Name/Base/Local/Models/ApiStyle? (`null`=OpenAI-совм., `"anthropic"`). | | `Application/AiProviderDefinition.cs` | record | Id/Name/Base/Local/Models/ApiStyle? (`null`=OpenAI-совм., `"anthropic"`). |
| `Application/AiProviders.cs` | static class | Список 7 провайдеров. | | `Application/AiProviders.cs` | static class | Список 7 провайдеров. |
| `Application/MockRates.cs` | static class | Мок-курсы + `RatesFetchInterval` (6 ч). | | `Application/MockRates.cs` | static class | Мок-курсы + `RatesFetchInterval` (6 ч). |
| `Application/ISettingsStore.cs` | interface | Порт KV-хранилища (см. ниже). | | `Application/ISettingsStore.cs` | interface | Порт KV-хранилища (см. ниже). |
| `Application/Models/SettingValue.cs` | record | `(Key, ValueJson, UpdatedAt)` — элемент порта. | | `Application/Models/SettingValue.cs` | record | `(Key, ValueJson, UpdatedAt)` — элемент порта. |
| `Application/Models/AiConfigSetting.cs` | record | Внутренняя (БД) форма конфига провайдера: `(ApiKey, BaseUrl, Model)`. | | `Application/Models/AiConfigSetting.cs` | record | Внутренняя (БД) форма конфига провайдера: `(ApiKey, BaseUrl, Model)`. |
| `Application/Models/TgKeysSetting.cs` | record | Внутренняя (БД) форма: `(ApiId, ApiHash)`. | | `Application/Models/TgKeysSetting.cs` | record | Внутренняя (БД) форма: `(ApiId, ApiHash)`. |
| `Application/Models/MyPromptDto.cs` | record | Элемент «Моих промптов» (public). | | `Application/Models/MyPromptDto.cs` | record | Элемент «Моих промптов» (public). |
| `Application/Models/AiConfigPublicDto.cs` | record | `(BaseUrl, Model, KeySet, KeyMasked)` — public-форма aiConfigs (Ruling 3). | | `Application/Models/AiConfigPublicDto.cs` | record | `(BaseUrl, Model, KeySet, KeyMasked)` — public-форма aiConfigs (Ruling 3). |
| `Application/Models/TgKeysPublicDto.cs` | record | `(ApiId, ApiHashSet)` — public-форма tgKeys (Ruling 3). | | `Application/Models/TgKeysPublicDto.cs` | record | `(ApiId, ApiHashSet)` — public-форма tgKeys (Ruling 3). |
| `Application/Models/ProviderPublicDto.cs` | record | `(Id, Name, Base, Local, Models)` — public-форма провайдера (без `api_style`). | | `Application/Models/ProviderPublicDto.cs` | record | `(Id, Name, Base, Local, Models)` — public-форма провайдера (без `api_style`). |
| `Application/Models/PublicSettingsDto.cs` | record | Все поля public-снимка §4.6, PascalCase-свойства (наружу camelCase даёт ASP.NET). | | `Application/Models/PublicSettingsDto.cs` | record | Все поля public-снимка §4.6, PascalCase-свойства (наружу camelCase даёт ASP.NET). |
| `tests/Deal.Tests.Unit/SettingsCatalogTests.cs` | xUnit | 13 тестов (см. ниже). | | `tests/Deal.Tests.Unit/SettingsCatalogTests.cs` | xUnit | 13 тестов (см. ниже). |
## Каталог ключей (`SettingsKeys.PublicKeys`, 39 ключей) ## Каталог ключей (`SettingsKeys.PublicKeys`, 39 ключей)
- **Int (9):** `archiveAfterDays`, `archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discEvalSample`, `discEvalThreshold`. - **Int (9):** `archiveAfterDays`, `archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discEvalSample`, `discEvalThreshold`.
- **Bool (11):** `autoArchive`, `aiEnabled`, `aiFilterEnabled`, `conversionOn`, `remindersEnabled`, `mlEnabled`, `blockResumes`, `budgetRequiredHire`, `budgetRequiredOrder`, `autoMonitorNew`, `discPaused`. - **Bool (11):** `autoArchive`, `aiEnabled`, `aiFilterEnabled`, `conversionOn`, `remindersEnabled`, `mlEnabled`, `blockResumes`, `budgetRequiredHire`, `budgetRequiredOrder`, `autoMonitorNew`, `discPaused`.
- **String (10):** `targetCurrency`, `rateSource`, `aiProvider`, `aiPrompt`, `aiFilterPrompt`, `cardPrompt`, `domainDescription`, `wantedType`, `hireLabel`, `orderLabel`. - **String (10):** `targetCurrency`, `rateSource`, `aiProvider`, `aiPrompt`, `aiFilterPrompt`, `cardPrompt`, `domainDescription`, `wantedType`, `hireLabel`, `orderLabel`.
- **List (5):** `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`. - **List (5):** `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`.
- **Dict (1):** `colState`. - **Dict (1):** `colState`.
- **special:** `myPrompts` (MyPrompts), `aiConfigs` (AiConfigs), `tgKeys` (TgKeys). - **special:** `myPrompts` (MyPrompts), `aiConfigs` (AiConfigs), `tgKeys` (TgKeys).
- **Внутренние (константы, вне `PublicKeys` — Ruling 1):** `ratesCache`, `mlDecisions`, `aiDecisions`. - **Внутренние (константы, вне `PublicKeys` — Ruling 1):** `ratesCache`, `mlDecisions`, `aiDecisions`.
Список сверен с планом Task 2 (L152161) и api-map §4.6/PATCH L340 (тест `PublicKeys_CoverAllApiMapKeysWithCorrectCategories` — эталон из 39 wire-имён). Список сверен с планом Task 2 (L152161) и api-map §4.6/PATCH L340 (тест `PublicKeys_CoverAllApiMapKeysWithCorrectCategories` — эталон из 39 wire-имён).
## Источники значений ## Источники значений
- **Дефолты** — `backend/app/constants.py`: `DEFAULT_SETTINGS` L189245, стоп-фразы L55, маркеры найма L144–149, грейдов L151154, резюме L163167; `aiConfigs` — на каждого провайдера (пустой ключ, base, первая модель, constants.py L231234); `tgKeys={apiId:"",apiHash:""}` L236; `discPaused=false` — рантайм (в `DEFAULT_SETTINGS` нет; дефолт из GET прототипа, api-map L333). Ключевые значения, проверяемые приёмкой Task 5: `aiEnabled/mlEnabled=true`, `minLen=24`, `archiveAfterDays=14`, `stopPhrases` — 4 шт., `wantedType="both"`, `rateSource="cbr"`, `aiProvider="deepseek"`, `targetCurrency="RUB"`. - **Дефолты** — `backend/app/constants.py`: `DEFAULT_SETTINGS` L189245, стоп-фразы L55, маркеры найма L144–149, грейдов L151154, резюме L163167; `aiConfigs` — на каждого провайдера (пустой ключ, base, первая модель, constants.py L231234); `tgKeys={apiId:"",apiHash:""}` L236; `discPaused=false` — рантайм (в `DEFAULT_SETTINGS` нет; дефолт из GET прототипа, api-map L333). Ключевые значения, проверяемые приёмкой Task 5: `aiEnabled/mlEnabled=true`, `minLen=24`, `archiveAfterDays=14`, `stopPhrases` — 4 шт., `wantedType="both"`, `rateSource="cbr"`, `aiProvider="deepseek"`, `targetCurrency="RUB"`.
- **Промпты** — `src/frontend/src/data.js` L94–141 (фронт — высший авторитет; план L47, Task 2 L165167). В `constants.py` те же тексты L63–141, но с расхождениями в разбивке на строки и формулировке (например, пункт «title»), поэтому за основу взят data.js. Тексты скопированы построчно; сверка на данном шаге — маркерные тесты (`{domain}`, `{keywords}`, «О заявке», «страж входящих»); полное сравнение строк — в Task 7 (`PromptDefaultsTests`). - **Промпты** — `src/frontend/src/data.js` L94–141 (фронт — высший авторитет; план L47, Task 2 L165167). В `constants.py` те же тексты L63–141, но с расхождениями в разбивке на строки и формулировке (например, пункт «title»), поэтому за основу взят data.js. Тексты скопированы построчно; сверка на данном шаге — маркерные тесты (`{domain}`, `{keywords}`, «О заявке», «страж входящих»); полное сравнение строк — в Task 7 (`PromptDefaultsTests`).
- **Провайдеры** — `constants.py` `AI_PROVIDERS` L170186 (7 шт., значения совпадают с `data.js` AI_PROVIDERS L1780). `api_style="anthropic"` только у anthropic — внутреннее поле, в public-форму не выходит (Ruling 3). - **Провайдеры** — `constants.py` `AI_PROVIDERS` L170186 (7 шт., значения совпадают с `data.js` AI_PROVIDERS L1780). `api_style="anthropic"` только у anthropic — внутреннее поле, в public-форму не выходит (Ruling 3).
- **Мок-курсы** — `constants.py` `MOCK_RATES` L41–50; интервал 6 ч — `rates.py` L20 (Ruling 6). - **Мок-курсы** — `constants.py` `MOCK_RATES` L41–50; интервал 6 ч — `rates.py` L20 (Ruling 6).
## Порт `ISettingsStore` ## Порт `ISettingsStore`
```csharp ```csharp
Task<SettingValue?> GetAsync(string key, CancellationToken ct); Task<SettingValue?> GetAsync(string key, CancellationToken ct);
Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct); Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct);
Task SetAsync(string key, string valueJson, CancellationToken ct); Task SetAsync(string key, string valueJson, CancellationToken ct);
Task RemoveAsync(string key, CancellationToken ct); Task RemoveAsync(string key, CancellationToken ct);
``` ```
`SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt)` — 1:1 со строкой таблицы `settings` (сущность `TenantSettingEntity`: `Key`/`ValueJson`/`UpdatedAt` уже есть, новых EF-таблиц нет). `SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt)` — 1:1 со строкой таблицы `settings` (сущность `TenantSettingEntity`: `Key`/`ValueJson`/`UpdatedAt` уже есть, новых EF-таблиц нет).
## Тесты (`SettingsCatalogTests`, 13) ## Тесты (`SettingsCatalogTests`, 13)
Каталог покрывает §4.6 с корректной категорией; счётчик 39; внутренние ключи не в публичном каталоге (и wire-имена `ratesCache`/`mlDecisions`/`aiDecisions`); провайдеры — 7 шт., id/base/local/api_style/custom-models; MockRates — состав и RUB=1; дефолты Task 5-приёмки (вкл. `discPaused=false`, пустые `colState`/`myPrompts`); tgKeys пустые; aiConfigs посеяны на всех провайдеров с пустым ключом и первой моделью; промпт-маркеры. Каталог покрывает §4.6 с корректной категорией; счётчик 39; внутренние ключи не в публичном каталоге (и wire-имена `ratesCache`/`mlDecisions`/`aiDecisions`); провайдеры — 7 шт., id/base/local/api_style/custom-models; MockRates — состав и RUB=1; дефолты Task 5-приёмки (вкл. `discPaused=false`, пустые `colState`/`myPrompts`); tgKeys пустые; aiConfigs посеяны на всех провайдеров с пустым ключом и первой моделью; промпт-маркеры.
## Расхождения с планом (и почему) ## Расхождения с планом (и почему)
1. **Порт оперирует JSON-строками (`SettingValue.ValueJson`), а не `object?`-значениями** (план L173175: `GetAsync(key)→object?`, `SetAsync(key, object?)`). ТЗ задачи задаёт порт именно так: record `SettingValue(Key, ValueJson, UpdatedAt)` + `GetAllAsync/SetAsync/RemoveAsync` (+опц. `GetManyAsync`). Это 1:1 с колонками таблицы и снимает с адаптера (Task 4) необходимость знать типы значений: он становится простым маппером строк; сериализацию/десериализацию JSON держит модуль (он знает категории ключей). К плану добавлен `GetAsync` (план его требует; `GetManyAsync` не добавлялся — потребителей нет, YAGNI), `RemoveAsync` — по ТЗ. 1. **Порт оперирует JSON-строками (`SettingValue.ValueJson`), а не `object?`-значениями** (план L173175: `GetAsync(key)→object?`, `SetAsync(key, object?)`). ТЗ задачи задаёт порт именно так: record `SettingValue(Key, ValueJson, UpdatedAt)` + `GetAllAsync/SetAsync/RemoveAsync` (+опц. `GetManyAsync`). Это 1:1 с колонками таблицы и снимает с адаптера (Task 4) необходимость знать типы значений: он становится простым маппером строк; сериализацию/десериализацию JSON держит модуль (он знает категории ключей). К плану добавлен `GetAsync` (план его требует; `GetManyAsync` не добавлялся — потребителей нет, YAGNI), `RemoveAsync` — по ТЗ.
2. **DTO public-снимка созданы в Task 2** (`Application/Models/*Dto.cs`): план относит их в Task 3 (L187–189), но ТЗ задачи (п. 4 «Что сделать») включает record-DTO для public-снимка. Созданы только типы (без SettingsService) — Task 3 остаётся их потребителем. 2. **DTO public-снимка созданы в Task 2** (`Application/Models/*Dto.cs`): план относит их в Task 3 (L187–189), но ТЗ задачи (п. 4 «Что сделать») включает record-DTO для public-снимка. Созданы только типы (без SettingsService) — Task 3 остаётся их потребителем.
3. **Добавлены внутренние модели `AiConfigSetting`/`TgKeysSetting`** (в плане нет): нужны `SettingsDefaults` для точных дефолтов `aiConfigs`/`tgKeys` (план L164: «aiConfigs для каждого провайдера с первым model, tgKeys={apiId:"",apiHash:""}») без `object?`-словарей; переиспользуются Task 3 при (де)сериализации сохранённых значений. 3. **Добавлены внутренние модели `AiConfigSetting`/`TgKeysSetting`** (в плане нет): нужны `SettingsDefaults` для точных дефолтов `aiConfigs`/`tgKeys` (план L164: «aiConfigs для каждого провайдера с первым model, tgKeys={apiId:"",apiHash:""}») без `object?`-словарей; переиспользуются Task 3 при (де)сериализации сохранённых значений.
4. **`RatesFetchInterval` размещён в `MockRates`** (план L172 группирует его с MockRates в один файл): один файл — один тип, поэтому интервал стал членом класса `MockRates` (обе величины — константы раздела «курсы»). 4. **`RatesFetchInterval` размещён в `MockRates`** (план L172 группирует его с MockRates в один файл): один файл — один тип, поэтому интервал стал членом класса `MockRates` (обе величины — константы раздела «курсы»).
5. **Промпты — `static readonly`, не `const`** (план L165: «константы»): многострочные тексты в `const` невозможны; значения неизменяемы и инициализируются один раз (нормализация `\r\n``\n` и снятие завершающего перевода строки raw-литерала для идентичности строке шаблона data.js). 5. **Промпты — `static readonly`, не `const`** (план L165: «константы»): многострочные тексты в `const` невозможны; значения неизменяемы и инициализируются один раз (нормализация `\r\n``\n` и снятие завершающего перевода строки raw-литерала для идентичности строке шаблона data.js).
6. **Registrar не создавался** (п. 6 ТЗ «может быть заготовлена, если план требует»): план создаёт `SettingsModuleRegistrar` в Task 4 (L228) — здесь не требуется. 6. **Registrar не создавался** (п. 6 ТЗ «может быть заготовлена, если план требует»): план создаёт `SettingsModuleRegistrar` в Task 4 (L228) — здесь не требуется.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (11 проектов) dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 51; сбой: 0; успешно: 51; пропущено: 0 (было 38 → +13) dotnet test tests/Deal.Tests.Unit → всего: 51; сбой: 0; успешно: 51; пропущено: 0 (было 38 → +13)
grep 'Infrastructure|EntityFramework|Npgsql' в src/core/Deal.Modules.Settings → 0 совпадений grep 'Infrastructure|EntityFramework|Npgsql' в src/core/Deal.Modules.Settings → 0 совпадений
``` ```
@@ -1,56 +1,56 @@
# Task 3 — «SettingsService: public-снимок и частичное обновление (PATCH 1:1)» — отчёт # Task 3 — «SettingsService: public-снимок и частичное обновление (PATCH 1:1)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 87/87 PASS (было 51, добавлено 36 — `SettingsServiceTests`). Статус: **complete**. Build 0 warnings / 0 errors; тесты 87/87 PASS (было 51, добавлено 36 — `SettingsServiceTests`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 3 L184220, Rulings 1/3/9/10; референс `settings_routes.py` L75192, `store.js` applySettings, api-map §4.6/§4.7). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 3 L184220, Rulings 1/3/9/10; референс `settings_routes.py` L75192, `store.js` applySettings, api-map §4.6/§4.7).
## Файлы ## Файлы
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `src/core/Deal.Modules.Settings/Application/SettingsService.cs` | class | Сервис: `GetPublicAsync` + `ApplyPatchAsync` + приватные хелперы (клампы, маски, merge дефолтов и переопределений). | | `src/core/Deal.Modules.Settings/Application/SettingsService.cs` | class | Сервис: `GetPublicAsync` + `ApplyPatchAsync` + приватные хелперы (клампы, маски, merge дефолтов и переопределений). |
| `src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs` | class | In-memory `ISettingsStore` (JSON-строки) с `Preload/GetStoredJson/Keys` для проверки «что ушло в БД». | | `src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs` | class | In-memory `ISettingsStore` (JSON-строки) с `Preload/GetStoredJson/Keys` для проверки «что ушло в БД». |
| `src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs` | class | Детерминированный шифр «enc: + Base64»: честен для сценария «сбойный токен → ""». | | `src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs` | class | Детерминированный шифр «enc: + Base64»: честен для сценария «сбойный токен → ""». |
| `src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs` | xUnit | 36 тестов (см. ниже). | | `src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs` | xUnit | 36 тестов (см. ниже). |
DTO не создавались — используются записи Task 2 (`PublicSettingsDto`, `AiConfigPublicDto`, `TgKeysPublicDto`, `AiConfigSetting`, `TgKeysSetting`, `MyPromptDto`, `SettingValue`). DTO не создавались — используются записи Task 2 (`PublicSettingsDto`, `AiConfigPublicDto`, `TgKeysPublicDto`, `AiConfigSetting`, `TgKeysSetting`, `MyPromptDto`, `SettingValue`).
## Поведение и сигнатуры (по плану Task 3 L190193) ## Поведение и сигнатуры (по плану Task 3 L190193)
```csharp ```csharp
public sealed class SettingsService(ISettingsStore store, ISecretCipher secretCipher) public sealed class SettingsService(ISettingsStore store, ISecretCipher secretCipher)
public Task<PublicSettingsDto> GetPublicAsync(CancellationToken ct); public Task<PublicSettingsDto> GetPublicAsync(CancellationToken ct);
public Task<PublicSettingsDto> ApplyPatchAsync(Dictionary<string, JsonElement> body, CancellationToken ct); public Task<PublicSettingsDto> ApplyPatchAsync(Dictionary<string, JsonElement> body, CancellationToken ct);
``` ```
- **GET/снимок** = дефолты `SettingsDefaults`, перекрытые сохранёнными переопределениями из `ISettingsStore` (GetAllAsync → JSON). Маски Ruling 3: `aiConfigs {baseUrl, model, keySet, keyMasked}`, `tgKeys {apiId: маска, apiHashSet}`; ключи расшифровываются через `ISecretCipher.Decrypt` (сбойный/чужой `enc:``""``keySet=false`, изоляция T1). Внутренние ключи (`ratesCache`/`mlDecisions`/`aiDecisions`) в снимок не входят (Ruling 1). Повреждённая JSON-строка в БД не роняет снимок (трактуется как отсутствующая). - **GET/снимок** = дефолты `SettingsDefaults`, перекрытые сохранёнными переопределениями из `ISettingsStore` (GetAllAsync → JSON). Маски Ruling 3: `aiConfigs {baseUrl, model, keySet, keyMasked}`, `tgKeys {apiId: маска, apiHashSet}`; ключи расшифровываются через `ISecretCipher.Decrypt` (сбойный/чужой `enc:``""``keySet=false`, изоляция T1). Внутренние ключи (`ratesCache`/`mlDecisions`/`aiDecisions`) в снимок не входят (Ruling 1). Повреждённая JSON-строка в БД не роняет снимок (трактуется как отсутствующая).
- **PATCH** = только переданные поля; ответ — полный снимок после применения (затем повторный `GetPublicAsync`). Неизвестные/внутренние ключи игнорируются (Ruling 1). Семантика 1:1 `settings_routes.py`: - **PATCH** = только переданные поля; ответ — полный снимок после применения (затем повторный `GetPublicAsync`). Неизвестные/внутренние ключи игнорируются (Ruling 1). Семантика 1:1 `settings_routes.py`:
- Int: клампы `archiveAfterDays 1..30`, `minLen 10..500`, `discJoinLimit 1..200`, `discJoinDelayMin/Max 5..600`, `discEvalSample 3..30`, `discEvalThreshold 1..100`; нечисловое → пропуск; `archiveClearDays`/`trashClearDays` без клампа. - Int: клампы `archiveAfterDays 1..30`, `minLen 10..500`, `discJoinLimit 1..200`, `discJoinDelayMin/Max 5..600`, `discEvalSample 3..30`, `discEvalThreshold 1..100`; нечисловое → пропуск; `archiveClearDays`/`trashClearDays` без клампа.
- Интервалы задержек: пара — клампы 5..600 затем swap при min>max; один конец — кламп относительно эффективного (дефолт+stored) другого конца (L80–109). - Интервалы задержек: пара — клампы 5..600 затем swap при min>max; один конец — кламп относительно эффективного (дефолт+stored) другого конца (L80–109).
- Bool: только JSON-булево (строки не «питон-булеватся»); String: `targetCurrency`→Upper, `aiProvider` вне `AiProviders` → пропуск; List: массив → строки, срез 200; Dict `colState` — passthrough raw JSON (Ruling 9). - Bool: только JSON-булево (строки не «питон-булеватся»); String: `targetCurrency`→Upper, `aiProvider` вне `AiProviders` → пропуск; List: массив → строки, срез 200; Dict `colState` — passthrough raw JSON (Ruling 9).
- `myPrompts`: срез 100 raw-элементов, trim+срезы name 80 / description 300 / prompt 8000, пустые name/prompt — дроп, id ≤40 или генерация `pp_`+8 hex. - `myPrompts`: срез 100 raw-элементов, trim+срезы name 80 / description 300 / prompt 8000, пустые name/prompt — дроп, id ≤40 или генерация `pp_`+8 hex.
- `aiConfigs`: только провайдеры каталога; `baseUrl`/`model` — строки; `apiKey` ≥8 без префикса `enc:``Encrypt` (в БД `enc:`); `keySet`/`keyMasked` наружу не пишутся, вычисляются при чтении. - `aiConfigs`: только провайдеры каталога; `baseUrl`/`model` — строки; `apiKey` ≥8 без префикса `enc:``Encrypt` (в БД `enc:`); `keySet`/`keyMasked` наружу не пишутся, вычисляются при чтении.
- `tgKeys`: `apiId` — только ASCII-цифры, длина 6..9 (stored открыто); `apiHash` ≥16 без `enc:``Encrypt`. - `tgKeys`: `apiId` — только ASCII-цифры, длина 6..9 (stored открыто); `apiHash` ≥16 без `enc:``Encrypt`.
- Хранение только переопределений: применённое значение aiConfigs/tgKeys, равное дефолту, в БД не пишется (`RemoveAsync`); секреты никогда не возвращаются в открытом виде (маска `1234…5678`, символ «…»). - Хранение только переопределений: применённое значение aiConfigs/tgKeys, равное дефолту, в БД не пишется (`RemoveAsync`); секреты никогда не возвращаются в открытом виде (маска `1234…5678`, символ «…»).
## Тесты (36, SettingsServiceTests) ## Тесты (36, SettingsServiceTests)
Снимок дефолтов на пустом хранилище; перекрытие дефолтов сохранёнными (int/list/colState/aiConfigs/tgKeys); маскирование (`sk-1…90ab`, apiId 10 цифр → `1234…7890`); сбойный `enc:` → keySet=false без исключения; PATCH меняет только переданные поля (1 строка в хранилище); неизвестные/внутренние ключи игнорируются; каждый Int-кламп + числовая строка + нечисловое; swap пары интервалов; одиночные концы (дефолтный и сохранённый другой конец); Bool-строка игнорируется, false хранится; `targetCurrency` Upper; `aiProvider` неизвестный/известный; List со скалярами и срез 200; myPrompts (clean+генерация pp_, дроп, trim, срезы 80/300/8000/40, срез 100, не-массив); aiConfigs (enc в store + `keySet/keyMasked`, короткий ключ не пишется, неизвестный провайдер игнорируется, baseUrl/model, сохранение ключа при follow-up patch без apiKey); tgKeys (enc хэша, не-цифровой apiId, короткий хэш, маска 9 цифр); colState passthrough (store raw + снимок); ответ PATCH == снимок последующего GET. Снимок дефолтов на пустом хранилище; перекрытие дефолтов сохранёнными (int/list/colState/aiConfigs/tgKeys); маскирование (`sk-1…90ab`, apiId 10 цифр → `1234…7890`); сбойный `enc:` → keySet=false без исключения; PATCH меняет только переданные поля (1 строка в хранилище); неизвестные/внутренние ключи игнорируются; каждый Int-кламп + числовая строка + нечисловое; swap пары интервалов; одиночные концы (дефолтный и сохранённый другой конец); Bool-строка игнорируется, false хранится; `targetCurrency` Upper; `aiProvider` неизвестный/известный; List со скалярами и срез 200; myPrompts (clean+генерация pp_, дроп, trim, срезы 80/300/8000/40, срез 100, не-массив); aiConfigs (enc в store + `keySet/keyMasked`, короткий ключ не пишется, неизвестный провайдер игнорируется, baseUrl/model, сохранение ключа при follow-up patch без apiKey); tgKeys (enc хэша, не-цифровой apiId, короткий хэш, маска 9 цифр); colState passthrough (store raw + снимок); ответ PATCH == снимок последующего GET.
## Расхождения (план vs бриф/прототип) ## Расхождения (план vs бриф/прототип)
1. **Имена методов**: бриф задачи называл `GetPublicSnapshotAsync`/`ApplyPatchAsync(SettingsPatchDto)`; план (файл, L190193) — `GetPublicAsync(ct)` и `ApplyPatchAsync(Dictionary<string, JsonElement> body, ct)`. Взят план: Task 5 («тело — произвольный JSON-объект») и мягкая семантика («невалидное поле просто не применяется») требуют словаря JsonElement, а не типизированного DTO (иначе ошибки десериализации тела). `SettingsPatchDto` не создавался. 1. **Имена методов**: бриф задачи называл `GetPublicSnapshotAsync`/`ApplyPatchAsync(SettingsPatchDto)`; план (файл, L190193) — `GetPublicAsync(ct)` и `ApplyPatchAsync(Dictionary<string, JsonElement> body, ct)`. Взят план: Task 5 («тело — произвольный JSON-объект») и мягкая семантика («невалидное поле просто не применяется») требуют словаря JsonElement, а не типизированного DTO (иначе ошибки десериализации тела). `SettingsPatchDto` не создавался.
2. **Приёмка Task 5 (L256257)**: `{discJoinDelayMin:700, discJoinDelayMax:5}` ожидает ответ `{5, 700}`; фактически по коду прототипа (L88–93: клампы ПЕРЕД swap) и семантике Task 3 (L199201) ответ `{5, 600}` (700 → 600, затем swap). Следовал коду прототипа; строку приёмки Task 5 считаю опечаткой (swap без клампов). 2. **Приёмка Task 5 (L256257)**: `{discJoinDelayMin:700, discJoinDelayMax:5}` ожидает ответ `{5, 700}`; фактически по коду прототипа (L88–93: клампы ПЕРЕД swap) и семантике Task 3 (L199201) ответ `{5, 600}` (700 → 600, затем swap). Следовал коду прототипа; строку приёмки Task 5 считаю опечаткой (swap без клампов).
3. **Bool**: прототип хранит `bool(value)` (строка `"false"` → true); по плану Task 3 строки не «питон-булеватся» — только JSON true/false. План приоритетнее. 3. **Bool**: прототип хранит `bool(value)` (строка `"false"` → true); по плану Task 3 строки не «питон-булеватся» — только JSON true/false. План приоритетнее.
4. **List**: не-скалярные элементы (объект/массив/null) пропускаются, а не `str()`-ятся как в Python (`"None"`, `"{...}"`); срез 200 после конвертации — 1:1. 4. **List**: не-скалярные элементы (объект/массив/null) пропускаются, а не `str()`-ятся как в Python (`"None"`, `"{...}"`); срез 200 после конвертации — 1:1.
5. **Int**: принимается JSON-число (int64) и числовая строка; float/булево/>int64 — «нечисловое» → пропуск (Python `int()` конвертирует шире). Реалистичные запросы фронта — целые. 5. **Int**: принимается JSON-число (int64) и числовая строка; float/булево/>int64 — «нечисловое» → пропуск (Python `int()` конвертирует шире). Реалистичные запросы фронта — целые.
6. **«Один конец интервала»** клампится относительно эффективного другого конца (дефолт+stored): в прототипе БД посеяна дефолтами, в нашей модели дефолты в коде — для пустого хранилища результат эквивалентен (мин. 700 → 70; макс. 5 → 50). 6. **«Один конец интервала»** клампится относительно эффективного другого конца (дефолт+stored): в прототипе БД посеяна дефолтами, в нашей модели дефолты в коде — для пустого хранилища результат эквивалентен (мин. 700 → 70; макс. 5 → 50).
7. **aiConfigs в БД** — полный эффективный словарь всех провайдеров (как в прототипе, где строка всегда полна); GET мержит дефолт+stored по провайдеру — устойчиво к дрейфу каталога. «Только существующие провайдеры» = каталог `AiProviders` (дефолтный aiConfigs покрывает всех). 7. **aiConfigs в БД** — полный эффективный словарь всех провайдеров (как в прототипе, где строка всегда полна); GET мержит дефолт+stored по провайдеру — устойчиво к дрейфу каталога. «Только существующие провайдеры» = каталог `AiProviders` (дефолтный aiConfigs покрывает всех).
8. **Побочные эффекты** (fire-and-forget `RatesService.RefreshAsync` при `rateSource`, пересчёт при `targetCurrency`/`conversionOn`, L186–192) в сервис не входят: Tasks 5/8; в этапе 2 leads нет. 8. **Побочные эффекты** (fire-and-forget `RatesService.RefreshAsync` при `rateSource`, пересчёт при `targetCurrency`/`conversionOn`, L186–192) в сервис не входят: Tasks 5/8; в этапе 2 leads нет.
9. **`Decrypt` без `enc:` → ""** (решение T1, legacy-данных нет) — legacy-открытый секрет показал бы `keySet=false`; запись всегда шифрует, строки-исключения нет. 9. **`Decrypt` без `enc:` → ""** (решение T1, legacy-данных нет) — legacy-открытый секрет показал бы `keySet=false`; запись всегда шифрует, строки-исключения нет.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → Сборка успешно, 0 предупреждений / 0 ошибок (11 проектов) dotnet build Deal.sln → Сборка успешно, 0 предупреждений / 0 ошибок (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 87; не пройдено: 0; успешно: 87 (было 51 → +36) dotnet test tests/Deal.Tests.Unit → всего: 87; не пройдено: 0; успешно: 87 (было 51 → +36)
``` ```
@@ -1,20 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Deal.SettingsDevCheck</RootNamespace> <RootNamespace>Deal.SettingsDevCheck</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\..\src\core\Deal.Infrastructure\Deal.Infrastructure.csproj" /> <ProjectReference Include="..\..\..\..\src\core\Deal.Infrastructure\Deal.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\..\src\core\Deal.Modules.Settings\Deal.Modules.Settings.csproj" /> <ProjectReference Include="..\..\..\..\src\core\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,115 +1,115 @@
using Deal.Infrastructure; using Deal.Infrastructure;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security; using Deal.Infrastructure.Security;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Npgsql; using Npgsql;
// Dev-проверка Task 4 (план L235236): KV-адаптер SettingsStore (EF) и DI против реального // Dev-проверка Task 4 (план L235236): KV-адаптер SettingsStore (EF) и DI против реального
// Postgres deal-postgres (:5433, контейнер deploy/compose.dev.yml). Проект лежит вне решения — // Postgres deal-postgres (:5433, контейнер deploy/compose.dev.yml). Проект лежит вне решения —
// в сборку/тесты src/core не входит. Сценарий = acceptance задачи: на пустой схеме тенанта // в сборку/тесты src/core не входит. Сценарий = acceptance задачи: на пустой схеме тенанта
// GET через сервис возвращает дефолты; SetAsync создаёт строку с value_json (её затем показывает // GET через сервис возвращает дефолты; SetAsync создаёт строку с value_json (её затем показывает
// psql); upsert/remove/updated_at — семантика адаптера. Проверочная схема devcheck_t4 // psql); upsert/remove/updated_at — семантика адаптера. Проверочная схема devcheck_t4
// создаётся tenant-миграциями (как TenantProvisioningService) и удаляется после psql-проверки. // создаётся tenant-миграциями (как TenantProvisioningService) и удаляется после psql-проверки.
const string baseConnection = "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; const string baseConnection = "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
const string schemaName = "devcheck_t4"; const string schemaName = "devcheck_t4";
const string migrationsHistoryTable = "__TenantMigrationsHistory"; const string migrationsHistoryTable = "__TenantMigrationsHistory";
int failures = 0; int failures = 0;
Console.WriteLine($"== Сброс и провижининг схемы {schemaName} (tenant-миграции) =="); Console.WriteLine($"== Сброс и провижининг схемы {schemaName} (tenant-миграции) ==");
await ExecSqlAsync($"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE; CREATE SCHEMA \"{schemaName}\";"); await ExecSqlAsync($"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE; CREATE SCHEMA \"{schemaName}\";");
var migrationOptions = new DbContextOptionsBuilder<TenantDbContext>() var migrationOptions = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql( .UseNpgsql(
$"{baseConnection};Search Path={schemaName}", $"{baseConnection};Search Path={schemaName}",
npgsql => npgsql.MigrationsHistoryTable(migrationsHistoryTable, schemaName)) npgsql => npgsql.MigrationsHistoryTable(migrationsHistoryTable, schemaName))
.Options; .Options;
await using (var migrator = new TenantDbContext(migrationOptions)) await using (var migrator = new TenantDbContext(migrationOptions))
{ {
await migrator.Database.MigrateAsync(); await migrator.Database.MigrateAsync();
} }
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("== DI: AddDealPersistence() + AddSettingsModule() + scoped TenantDbContext =="); Console.WriteLine("== DI: AddDealPersistence() + AddSettingsModule() + scoped TenantDbContext ==");
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddDbContext<TenantDbContext>(options => options.UseNpgsql($"{baseConnection};Search Path={schemaName}")); services.AddDbContext<TenantDbContext>(options => options.UseNpgsql($"{baseConnection};Search Path={schemaName}"));
services.AddDealPersistence(); services.AddDealPersistence();
// Ключ шифрования не важен: секреты (aiConfigs/tgKeys) в проверке не пишутся — только дефолтный снимок. // Ключ шифрования не важен: секреты (aiConfigs/tgKeys) в проверке не пишутся — только дефолтный снимок.
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(new byte[32])); services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(new byte[32]));
services.AddSettingsModule(); services.AddSettingsModule();
await using ServiceProvider provider = services.BuildServiceProvider(); await using ServiceProvider provider = services.BuildServiceProvider();
await using AsyncServiceScope scope = provider.CreateAsyncScope(); await using AsyncServiceScope scope = provider.CreateAsyncScope();
SettingsService settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); SettingsService settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
ISettingsStore store = scope.ServiceProvider.GetRequiredService<ISettingsStore>(); ISettingsStore store = scope.ServiceProvider.GetRequiredService<ISettingsStore>();
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("== 1. Пустая схема: GET через сервис -> дефолты =="); Console.WriteLine("== 1. Пустая схема: GET через сервис -> дефолты ==");
Check((await store.GetAllAsync(default)).Count == 0, "хранилище пусто (0 строк)"); Check((await store.GetAllAsync(default)).Count == 0, "хранилище пусто (0 строк)");
Check(await store.GetAsync(SettingsKeys.ArchiveAfterDays, default) is null, "GetAsync отсутствующего ключа -> null"); Check(await store.GetAsync(SettingsKeys.ArchiveAfterDays, default) is null, "GetAsync отсутствующего ключа -> null");
PublicSettingsDto snapshot = await settings.GetPublicAsync(default); PublicSettingsDto snapshot = await settings.GetPublicAsync(default);
Check(snapshot.AiEnabled, "aiEnabled=true"); Check(snapshot.AiEnabled, "aiEnabled=true");
Check(snapshot.MlEnabled, "mlEnabled=true"); Check(snapshot.MlEnabled, "mlEnabled=true");
Check(snapshot.MinLen == 24, "minLen=24"); Check(snapshot.MinLen == 24, "minLen=24");
Check(snapshot.ArchiveAfterDays == 14, "archiveAfterDays=14"); Check(snapshot.ArchiveAfterDays == 14, "archiveAfterDays=14");
Check(snapshot.StopPhrases.Count == 4, "stopPhrases: 4 дефолтные"); Check(snapshot.StopPhrases.Count == 4, "stopPhrases: 4 дефолтные");
Check(snapshot.WantedType == "both", "wantedType=both"); Check(snapshot.WantedType == "both", "wantedType=both");
Check(snapshot.RateSource == "cbr", "rateSource=cbr"); Check(snapshot.RateSource == "cbr", "rateSource=cbr");
Check(snapshot.AiProvider == "deepseek", "aiProvider=deepseek"); Check(snapshot.AiProvider == "deepseek", "aiProvider=deepseek");
Check(snapshot.TgKeys.ApiId == string.Empty && !snapshot.TgKeys.ApiHashSet, "tgKeys={apiId:\"\", apiHashSet:false}"); Check(snapshot.TgKeys.ApiId == string.Empty && !snapshot.TgKeys.ApiHashSet, "tgKeys={apiId:\"\", apiHashSet:false}");
Check(snapshot.ColState.Count == 0, "colState={}"); Check(snapshot.ColState.Count == 0, "colState={}");
Check(snapshot.Providers.Count == 7, "providers: 7 шт."); Check(snapshot.Providers.Count == 7, "providers: 7 шт.");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("== 2. SetAsync (upsert по PK) + Get/GetAll + updated_at UTC =="); Console.WriteLine("== 2. SetAsync (upsert по PK) + Get/GetAll + updated_at UTC ==");
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default); await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(default); IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(default);
Check(rows.Count == 1, "SetAsync создал 1 строку"); Check(rows.Count == 1, "SetAsync создал 1 строку");
SettingValue? row = await store.GetAsync(SettingsKeys.ArchiveAfterDays, default); SettingValue? row = await store.GetAsync(SettingsKeys.ArchiveAfterDays, default);
Check(row is not null && row.ValueJson == "30", "GetAsync вернул value_json=\"30\""); Check(row is not null && row.ValueJson == "30", "GetAsync вернул value_json=\"30\"");
Check(row is not null && DateTimeOffset.UtcNow - row.UpdatedAt < TimeSpan.FromMinutes(1), "updated_at ~ UTC-now"); Check(row is not null && DateTimeOffset.UtcNow - row.UpdatedAt < TimeSpan.FromMinutes(1), "updated_at ~ UTC-now");
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default); await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
Check((await store.GetAllAsync(default)).Count == 1, "повторный SetAsync не дублирует (upsert по PK)"); Check((await store.GetAllAsync(default)).Count == 1, "повторный SetAsync не дублирует (upsert по PK)");
await store.SetAsync(SettingsKeys.ColState, "{\"kanban\":true}", default); await store.SetAsync(SettingsKeys.ColState, "{\"kanban\":true}", default);
Check((await store.GetAllAsync(default)).Count == 2, "второй ключ — вторая строка"); Check((await store.GetAllAsync(default)).Count == 2, "второй ключ — вторая строка");
Check(await store.GetAsync(SettingsKeys.ColState, default) is { ValueJson: "{\"kanban\":true}" }, "value_json хранится текстом как есть"); Check(await store.GetAsync(SettingsKeys.ColState, default) is { ValueJson: "{\"kanban\":true}" }, "value_json хранится текстом как есть");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("== 3. RemoveAsync =="); Console.WriteLine("== 3. RemoveAsync ==");
await store.RemoveAsync("noSuchKey", default); await store.RemoveAsync("noSuchKey", default);
Check((await store.GetAllAsync(default)).Count == 2, "RemoveAsync отсутствующего ключа — no-op"); Check((await store.GetAllAsync(default)).Count == 2, "RemoveAsync отсутствующего ключа — no-op");
await store.RemoveAsync(SettingsKeys.ColState, default); await store.RemoveAsync(SettingsKeys.ColState, default);
Check((await store.GetAllAsync(default)).Count == 1, "RemoveAsync удалил существующую строку"); Check((await store.GetAllAsync(default)).Count == 1, "RemoveAsync удалил существующую строку");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("== 4. GET через сервис после SetAsync (переопределение дефолта) =="); Console.WriteLine("== 4. GET через сервис после SetAsync (переопределение дефолта) ==");
PublicSettingsDto after = await settings.GetPublicAsync(default); PublicSettingsDto after = await settings.GetPublicAsync(default);
Check(after.ArchiveAfterDays == 30, "archiveAfterDays=30 — сохранённое переопределило дефолт 14"); Check(after.ArchiveAfterDays == 30, "archiveAfterDays=30 — сохранённое переопределило дефолт 14");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine(failures == 0 ? "ИТОГ: все проверки прошли." : $"ИТОГ: не прошли проверок: {failures}."); Console.WriteLine(failures == 0 ? "ИТОГ: все проверки прошли." : $"ИТОГ: не прошли проверок: {failures}.");
Console.WriteLine($"Схема {schemaName} оставлена с 1 строкой settings для psql-проверки value_json."); Console.WriteLine($"Схема {schemaName} оставлена с 1 строкой settings для psql-проверки value_json.");
Environment.ExitCode = failures == 0 ? 0 : 1; Environment.ExitCode = failures == 0 ? 0 : 1;
void Check(bool condition, string description) void Check(bool condition, string description)
{ {
Console.WriteLine($" {(condition ? "[PASS]" : "[FAIL]")} {description}"); Console.WriteLine($" {(condition ? "[PASS]" : "[FAIL]")} {description}");
if (!condition) if (!condition)
{ {
failures++; failures++;
} }
} }
async Task ExecSqlAsync(string sql) async Task ExecSqlAsync(string sql)
{ {
await using var connection = new NpgsqlConnection(baseConnection); await using var connection = new NpgsqlConnection(baseConnection);
await connection.OpenAsync(); await connection.OpenAsync();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = sql; command.CommandText = sql;
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
@@ -1,79 +1,79 @@
# Task 4 — «KV-адаптер SettingsStore (EF) и DI» — отчёт # Task 4 — «KV-адаптер SettingsStore (EF) и DI» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (было 87, добавлен 1); Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (было 87, добавлен 1);
psql-приёмка на реальном `deal-postgres` (:5433) — все проверки PASS (см. ниже). psql-приёмка на реальном `deal-postgres` (:5433) — все проверки PASS (см. ниже).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 4 L220236, Ruling 1; Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 4 L220236, Ruling 1;
эталон — `AuthStore.cs`/`TenantRepository.cs`, `TenantModuleRegistrar.cs`, `ServiceCollectionExtensions.cs`). эталон — `AuthStore.cs`/`TenantRepository.cs`, `TenantModuleRegistrar.cs`, `ServiceCollectionExtensions.cs`).
## Файлы ## Файлы
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs` | class | EF-адаптер `ISettingsStore` поверх `TenantDbContext.Settings` (таблица settings tenant-схемы, сущность `TenantSettingEntity` уже была — миграции/таблицы НЕ добавлялись). Маппинг 1:1 `SettingValue(Key, ValueJson, UpdatedAt)` ↔ сущность; `value_json` хранится текстом без интерпретации (сериализацию выполняет модуль Settings — см. порт). | | `src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs` | class | EF-адаптер `ISettingsStore` поверх `TenantDbContext.Settings` (таблица settings tenant-схемы, сущность `TenantSettingEntity` уже была — миграции/таблицы НЕ добавлялись). Маппинг 1:1 `SettingValue(Key, ValueJson, UpdatedAt)` ↔ сущность; `value_json` хранится текстом без интерпретации (сериализацию выполняет модуль Settings — см. порт). |
| `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealPersistence()`: добавлено `AddScoped<ISettingsStore, SettingsStore>()`; XML-doc метода дополнен. | | `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealPersistence()`: добавлено `AddScoped<ISettingsStore, SettingsStore>()`; XML-doc метода дополнен. |
| `src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | class | `AddSettingsModule()`: `AddScoped<SettingsService>()` (паттерн `TenantModuleRegistrar`; вызов из `Program.cs` — Task 5). | | `src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | class | `AddSettingsModule()`: `AddScoped<SettingsService>()` (паттерн `TenantModuleRegistrar`; вызов из `Program.cs` — Task 5). |
| `src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj` | modify | `PackageReference Microsoft.Extensions.DependencyInjection.Abstractions 10.0.11` (для регистратора). | | `src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj` | modify | `PackageReference Microsoft.Extensions.DependencyInjection.Abstractions 10.0.11` (для регистратора). |
| `src/core/Deal.Api/Deal.Api.csproj` | modify | `ProjectReference` на `Deal.Modules.Settings` (план Task 4; вызов модуля в Program.cs — Task 5). | | `src/core/Deal.Api/Deal.Api.csproj` | modify | `ProjectReference` на `Deal.Modules.Settings` (план Task 4; вызов модуля в Program.cs — Task 5). |
| `src/core/tests/Deal.Tests.Unit/TenantSettingEntityTests.cs` | xUnit | Лёгкий тест сущности `TenantSettingEntity` (по образцу `TenantEntityTests`). | | `src/core/tests/Deal.Tests.Unit/TenantSettingEntityTests.cs` | xUnit | Лёгкий тест сущности `TenantSettingEntity` (по образцу `TenantEntityTests`). |
| `.superpowers/sdd/deal-stage2-settings/task-4-devcheck/` (csproj+Program.cs) | dev-check | Консольная приёмка адаптера против реального Postgres (вне решения, в сборку не входит). | | `.superpowers/sdd/deal-stage2-settings/task-4-devcheck/` (csproj+Program.cs) | dev-check | Консольная приёмка адаптера против реального Postgres (вне решения, в сборку не входит). |
`Deal.Infrastructure.csproj` уже ссылался на `Deal.Modules.Settings` — изменений не потребовалось. `Deal.Infrastructure.csproj` уже ссылался на `Deal.Modules.Settings` — изменений не потребовалось.
## Поведение адаптера ## Поведение адаптера
- `GetAsync``AsNoTracking`, `SingleOrDefaultAsync` по PK `Key`; отсутствующий ключ → `null` (модуль трактует как дефолт). - `GetAsync``AsNoTracking`, `SingleOrDefaultAsync` по PK `Key`; отсутствующий ключ → `null` (модуль трактует как дефолт).
- `GetAllAsync` — все строки, `AsNoTracking`, упорядочены по `Key`. - `GetAllAsync` — все строки, `AsNoTracking`, упорядочены по `Key`.
- `SetAsync` — upsert по PK: существующая строка обновляется, отсутствующая добавляется; `UpdatedAt = DateTimeOffset.UtcNow`; сохранение — `SaveChangesAsync` (в стиле `AuthStore.CreateSessionAsync`). - `SetAsync` — upsert по PK: существующая строка обновляется, отсутствующая добавляется; `UpdatedAt = DateTimeOffset.UtcNow`; сохранение — `SaveChangesAsync` (в стиле `AuthStore.CreateSessionAsync`).
- `RemoveAsync``ExecuteDeleteAsync` по ключу: существующая строка удаляется, отсутствующий ключ — no-op (в стиле `AuthStore.DeleteSessionAsync`). - `RemoveAsync``ExecuteDeleteAsync` по ключу: существующая строка удаляется, отсутствующий ключ — no-op (в стиле `AuthStore.DeleteSessionAsync`).
- Маппинг DTO ↔ сущности — вручную (порт модуля не видит EF-сущности), как в эталонных адаптерах. - Маппинг DTO ↔ сущности — вручную (порт модуля не видит EF-сущности), как в эталонных адаптерах.
## Отклонения и решения ## Отклонения и решения
1. **`SettingsModuleRegistrar` регистрирует только `SettingsService`.** Список файлов Task 4 упоминает `RatesService` и 1. **`SettingsModuleRegistrar` регистрирует только `SettingsService`.** Список файлов Task 4 упоминает `RatesService` и
`IncomingRules` — их типов в модуле ещё нет (задачи 8/10), регистрация несуществующих типов сломала бы build 0/0. `IncomingRules` — их типов в модуле ещё нет (задачи 8/10), регистрация несуществующих типов сломала бы build 0/0.
Регистратор расширяется по мере появления сервисов модуля (задачи 8/10 добавляют свои строки). Регистратор расширяется по мере появления сервисов модуля (задачи 8/10 добавляют свои строки).
2. **Вызов `AddSettingsModule()` в `Program.cs` и runtime-регистрация scoped `TenantDbContext` — Task 5** (план: «вызывается 2. **Вызов `AddSettingsModule()` в `Program.cs` и runtime-регистрация scoped `TenantDbContext` — Task 5** (план: «вызывается
в A/Program.cs (Task 5)», L229). Поэтому HTTP/curl-часть приёмки (`GET /api/settings`) невозможна до Task 5. в A/Program.cs (Task 5)», L229). Поэтому HTTP/curl-часть приёмки (`GET /api/settings`) невозможна до Task 5.
3. **psql-приёмка выполнена dev-check-харнессом** (п. «Проверки»): эндпоинтов ещё нет, поэтому acceptance исполнен на уровне 3. **psql-приёмка выполнена dev-check-харнессом** (п. «Проверки»): эндпоинтов ещё нет, поэтому acceptance исполнен на уровне
«сервис + адаптер» против реального `deal-postgres` на временной схеме `devcheck_t4` (провижининг — tenant-миграцией, «сервис + адаптер» против реального `deal-postgres` на временной схеме `devcheck_t4` (провижининг — tenant-миграцией,
как `TenantProvisioningService`), после проверки схема удалена. Схема дефолтного тенанта не тронута (0 строк settings, как было). как `TenantProvisioningService`), после проверки схема удалена. Схема дефолтного тенанта не тронута (0 строк settings, как было).
4. **Unit-тест адаптера без БД невозможен** (EF-провайдер нужен); InMemory по конвенции проекта не используется. 4. **Unit-тест адаптера без БД невозможен** (EF-провайдер нужен); InMemory по конвенции проекта не используется.
В unit добавлен лёгкий тест сущности-носителя (образец `TenantEntityTests`), полный сценарий — dev-проверка выше. В unit добавлен лёгкий тест сущности-носителя (образец `TenantEntityTests`), полный сценарий — dev-проверка выше.
5. **Ключ шифрования в dev-check — нулевой** (32 нулевых байта): секреты aiConfigs/tgKeys в проверке не пишутся, 5. **Ключ шифрования в dev-check — нулевой** (32 нулевых байта): секреты aiConfigs/tgKeys в проверке не пишутся,
используется только дефолтный снимок (AES-операций нет) — достаточно для проверки KV-адаптера и DI. используется только дефолтный снимок (AES-операций нет) — достаточно для проверки KV-адаптера и DI.
## Проверки ## Проверки
### build + unit ### build + unit
``` ```
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0. (11 проектов) dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0. (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88 (было 87 → +1) dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88 (было 87 → +1)
``` ```
### psql / dev-check (acceptance Task 4, L235236) ### psql / dev-check (acceptance Task 4, L235236)
Харнесс (схема `devcheck_t4`, tenant-миграция `InitialTenant` применена; DI: `AddDealPersistence` + Харнесс (схема `devcheck_t4`, tenant-миграция `InitialTenant` применена; DI: `AddDealPersistence` +
`AddSettingsModule` + scoped `TenantDbContext`) — все 24 проверки `[PASS]`: `AddSettingsModule` + scoped `TenantDbContext`) — все 24 проверки `[PASS]`:
1. **Пустая схема: GET через сервис → дефолты** — `aiEnabled:true, mlEnabled:true, minLen:24, 1. **Пустая схема: GET через сервис → дефолты** — `aiEnabled:true, mlEnabled:true, minLen:24,
archiveAfterDays:14, stopPhrases:4, wantedType:"both", rateSource:"cbr", aiProvider:"deepseek", archiveAfterDays:14, stopPhrases:4, wantedType:"both", rateSource:"cbr", aiProvider:"deepseek",
tgKeys:{apiId:"",apiHashSet:false}, colState:{}, providers:7` (совпадает с Task 5-приёмкой L253255); tgKeys:{apiId:"",apiHashSet:false}, colState:{}, providers:7` (совпадает с Task 5-приёмкой L253255);
`GetAsync` отсутствующего ключа → null. `GetAsync` отсутствующего ключа → null.
2. **SetAsync создаёт строку** — после `SetAsync("archiveAfterDays","30")`: 2. **SetAsync создаёт строку** — после `SetAsync("archiveAfterDays","30")`:
`GetAllAsync` → 1 строка, `GetAsync``value_json="30"`, `updated_at` ≈ UTC-now; повторный SetAsync `GetAllAsync` → 1 строка, `GetAsync``value_json="30"`, `updated_at` ≈ UTC-now; повторный SetAsync
не дублирует (upsert по PK); второй ключ — вторая строка; JSON хранится текстом как есть. не дублирует (upsert по PK); второй ключ — вторая строка; JSON хранится текстом как есть.
3. **RemoveAsync** — отсутствующий ключ no-op; существующая строка удаляется. 3. **RemoveAsync** — отсутствующий ключ no-op; существующая строка удаляется.
4. **GET через сервис после записи**`archiveAfterDays:30` (переопределение дефолта 14). 4. **GET через сервис после записи**`archiveAfterDays:30` (переопределение дефолта 14).
psql после прогона (строка на месте, до очистки схемы): psql после прогона (строка на месте, до очистки схемы):
``` ```
$ docker exec deal-postgres psql -U deal -d deal -c 'SELECT "Key", "ValueJson", "UpdatedAt" FROM devcheck_t4.settings;' $ docker exec deal-postgres psql -U deal -d deal -c 'SELECT "Key", "ValueJson", "UpdatedAt" FROM devcheck_t4.settings;'
Key | ValueJson | UpdatedAt Key | ValueJson | UpdatedAt
------------------+-----------+------------------------------- ------------------+-----------+-------------------------------
archiveAfterDays | 30 | 2026-09-06 01:13:55.443698+00 archiveAfterDays | 30 | 2026-09-06 01:13:55.443698+00
``` ```
Очистка: `DROP SCHEMA devcheck_t4 CASCADE` выполнен; `\dn` — только public + tenant_…001; в схеме дефолтного Очистка: `DROP SCHEMA devcheck_t4 CASCADE` выполнен; `\dn` — только public + tenant_…001; в схеме дефолтного
тенанта settings пуста (как до проверки). тенанта settings пуста (как до проверки).
@@ -1,286 +1,286 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 5 curl-приёмка GET/PATCH /api/settings на :5080 (план Task 5 L252263). # Task 5 curl-приёмка GET/PATCH /api/settings на :5080 (план Task 5 L252263).
# Сценарий: 401 без куки → login admin/admin → GET дефолты → PATCH клампы+swap → # Сценарий: 401 без куки → login admin/admin → GET дефолты → PATCH клампы+swap →
# myPrompts → aiConfigs (deepseek apiKey) → tgKeys → поля (archiveAfterDays/minLen/stopPhrases/colState) # myPrompts → aiConfigs (deepseek apiKey) → tgKeys → поля (archiveAfterDays/minLen/stopPhrases/colState)
# → GET: изменения видны, секреты замаскированы → PATCH невалидных клампов повторно → # → GET: изменения видны, секреты замаскированы → PATCH невалидных клампов повторно →
# неизвестный ключ → logout. В конце psql: строки settings + enc:. Вывод всех шагов в stdout. # неизвестный ключ → logout. В конце psql: строки settings + enc:. Вывод всех шагов в stdout.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
CORE_DIR="C:/telbase/src/core" CORE_DIR="C:/telbase/src/core"
API_DIR="$CORE_DIR/Deal.Api" API_DIR="$CORE_DIR/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task5-jar.txt" JAR="/tmp/task5-jar.txt"
OUT="/tmp/task5-out.txt" OUT="/tmp/task5-out.txt"
LOG="/tmp/task5-api.log" LOG="/tmp/task5-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
check_count() { check_count() {
# $1 — описание; $2 — подстрока; $3 — ожидаемое число вхождений # $1 — описание; $2 — подстрока; $3 — ожидаемое число вхождений
desc=$1 desc=$1
pat=$2 pat=$2
want=$3 want=$3
got=$(grep -oF -- "$pat" "$OUT" | wc -l) got=$(grep -oF -- "$pat" "$OUT" | wc -l)
if [ "$got" = "$want" ]; then if [ "$got" = "$want" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc ($got)" echo " [PASS] $desc ($got)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено $got, ожидалось $want" echo " [FAIL] $desc — найдено $got, ожидалось $want"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" /tmp/task5-body8.json rm -f "$JAR" "$OUT" "$LOG" /tmp/task5-body8.json
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторные прогоны # Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторные прогоны
# и прерванные запуски могли оставить переопределения). Чистим до старта сервера. # и прерванные запуски могли оставить переопределения). Чистим до старта сервера.
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
# Ждём health (до 30 с; bootstrap провижинит схему дефолтного тенанта до первого ответа). # Ждём health (до 30 с; bootstrap провижинит схему дефолтного тенанта до первого ответа).
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. GET /api/settings без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} ==" echo "== 1. GET /api/settings без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 1b. PATCH /api/settings без куки — ожидаем 401 ==" echo "== 1b. PATCH /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"archiveAfterDays":7}' > "$OUT" -H "Content-Type: application/json" -d '{"archiveAfterDays":7}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 PATCH без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 PATCH без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 2b. PATCH /api/settings невалидный JSON — ожидаем 400 {detail} ==" echo "== 2b. PATCH /api/settings невалидный JSON — ожидаем 400 {detail} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"archiveAfterDays":' > "$OUT" -H "Content-Type: application/json" -d '{"archiveAfterDays":' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "400 на не-JSON тело" '[HTTP:400]' '"detail":"Тело запроса должно быть JSON-объектом"' check "400 на не-JSON тело" '[HTTP:400]' '"detail":"Тело запроса должно быть JSON-объектом"'
echo echo
echo "== 3. GET /api/settings — дефолты (пустая таблица settings) ==" echo "== 3. GET /api/settings — дефолты (пустая таблица settings) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "int-дефолты" '"archiveAfterDays":14' '"minLen":24' check "int-дефолты" '"archiveAfterDays":14' '"minLen":24'
check "bool-дефолты" '"autoArchive":true' '"aiEnabled":true' '"mlEnabled":true' check "bool-дефолты" '"autoArchive":true' '"aiEnabled":true' '"mlEnabled":true'
check "string-дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"' check "string-дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
check "stopPhrases — 4 дефолтные" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]' check "stopPhrases — 4 дефолтные" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
check "tgKeys пустые" '"apiId":""' '"apiHashSet":false' check "tgKeys пустые" '"apiId":""' '"apiHashSet":false'
check "colState пустой" '"colState":{}' check "colState пустой" '"colState":{}'
check "aiConfigs deepseek пустой ключ" '"deepseek":' '"keySet":false' '"keyMasked":""' check "aiConfigs deepseek пустой ключ" '"deepseek":' '"keySet":false' '"keyMasked":""'
check_count "providers — 7 провайдеров" '"id":"' 7 check_count "providers — 7 провайдеров" '"id":"' 7
echo echo
echo "== 4. PATCH клампы+swap {archiveAfterDays:99, minLen:3, delayMin:700, delayMax:5} ==" echo "== 4. PATCH клампы+swap {archiveAfterDays:99, minLen:3, delayMin:700, delayMax:5} =="
echo " ожидаем archiveAfterDays:30, minLen:10, delayMin:5, delayMax:600 (клампы 5..600 ПЕРЕД swap)" echo " ожидаем archiveAfterDays:30, minLen:10, delayMin:5, delayMax:600 (клампы 5..600 ПЕРЕД swap)"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT" -d '{"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "клампы применены" '"archiveAfterDays":30' '"minLen":10' check "клампы применены" '"archiveAfterDays":30' '"minLen":10'
check "интервал задержек {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600' check "интервал задержек {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
echo echo
echo "== 5. PATCH myPrompts [{name:x,prompt:y},{name:empty}] → 1 элемент c id pp_ ==" echo "== 5. PATCH myPrompts [{name:x,prompt:y},{name:empty}] → 1 элемент c id pp_ =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"myPrompts":[{"name":"x","prompt":"y"},{"name":"","prompt":""}]}' > "$OUT" -d '{"myPrompts":[{"name":"x","prompt":"y"},{"name":"","prompt":""}]}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check_count "ровно 1 промпт" '"name":"x"' 1 check_count "ровно 1 промпт" '"name":"x"' 1
check "id генерируется с префиксом pp_" '"id":"pp_' check "id генерируется с префиксом pp_" '"id":"pp_'
echo echo
echo "== 6. PATCH aiConfigs.deepseek.apiKey sk-1234567890ab ==" echo "== 6. PATCH aiConfigs.deepseek.apiKey sk-1234567890ab =="
echo " ожидаем keySet:true, keyMasked: sk-1…90ab" echo " ожидаем keySet:true, keyMasked: sk-1…90ab"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}' > "$OUT" -d '{"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "deepseek keySet/mask" '"keySet":true' '"keyMasked":"sk-1…90ab"' check "deepseek keySet/mask" '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo echo
echo "== 7. PATCH tgKeys {apiId:123456, apiHash:abcdefghijklmnop} → apiHashSet:true ==" echo "== 7. PATCH tgKeys {apiId:123456, apiHash:abcdefghijklmnop} → apiHashSet:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}' > "$OUT" -d '{"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "apiId как есть (len<=8) и apiHashSet true" '"apiId":"123456"' '"apiHashSet":true' check "apiId как есть (len<=8) и apiHashSet true" '"apiId":"123456"' '"apiHashSet":true'
echo echo
# Тело с кириллицей передаём файлом (UTF-8): в Windows curl аргумент командной строки # Тело с кириллицей передаём файлом (UTF-8): в Windows curl аргумент командной строки
# с кириллицей перекодируется в cp1251, и сервер получает невалидный UTF-8. # с кириллицей перекодируется в cp1251, и сервер получает невалидный UTF-8.
cat > /tmp/task5-body8.json <<'BODY8' cat > /tmp/task5-body8.json <<'BODY8'
{"archiveAfterDays":7,"minLen":30,"stopPhrases":["стоп раз","стоп два"],"colState":{"review":1,"done":2}} {"archiveAfterDays":7,"minLen":30,"stopPhrases":["стоп раз","стоп два"],"colState":{"review":1,"done":2}}
BODY8 BODY8
echo "== 8. PATCH поля: archiveAfterDays=7, minLen=30, stopPhrases, colState ==" echo "== 8. PATCH поля: archiveAfterDays=7, minLen=30, stopPhrases, colState =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @/tmp/task5-body8.json > "$OUT" -H "Content-Type: application/json" --data-binary @/tmp/task5-body8.json > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "новые значения в ответе" '"archiveAfterDays":7' '"minLen":30' check "новые значения в ответе" '"archiveAfterDays":7' '"minLen":30'
check "stopPhrases заменены" '"stopPhrases":["стоп раз","стоп два"]' check "stopPhrases заменены" '"stopPhrases":["стоп раз","стоп два"]'
check "colState passthrough" '"colState":{"review":1,"done":2}' check "colState passthrough" '"colState":{"review":1,"done":2}'
echo echo
echo "== 9. GET /api/settings — финальная проверка: изменения видны, секреты замаскированы ==" echo "== 9. GET /api/settings — финальная проверка: изменения видны, секреты замаскированы =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "переопределения применены" '"archiveAfterDays":7' '"minLen":30' check "переопределения применены" '"archiveAfterDays":7' '"minLen":30'
check "stopPhrases новые" '"stopPhrases":["стоп раз","стоп два"]' check "stopPhrases новые" '"stopPhrases":["стоп раз","стоп два"]'
check "colState новый" '"colState":{"review":1,"done":2}' check "colState новый" '"colState":{"review":1,"done":2}'
check "myPrompts сохранён" '"name":"x"' '"id":"pp_' check "myPrompts сохранён" '"name":"x"' '"id":"pp_'
check "deepseek keySet+маска" '"keySet":true' '"keyMasked":"sk-1…90ab"' check "deepseek keySet+маска" '"keySet":true' '"keyMasked":"sk-1…90ab"'
if grep -qF "sk-1234567890ab" "$OUT"; then if grep -qF "sk-1234567890ab" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] открытый ключ sk-1234567890ab утёк в ответ GET" echo " [FAIL] открытый ключ sk-1234567890ab утёк в ответ GET"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] открытого ключа в ответе GET нет (только маска sk-1…90ab)" echo " [PASS] открытого ключа в ответе GET нет (только маска sk-1…90ab)"
fi fi
check "tgKeys apiHashSet" '"apiHashSet":true' check "tgKeys apiHashSet" '"apiHashSet":true'
echo echo
echo "== 10. PATCH невалидных клампов повторно {archiveAfterDays:99, delayMin:700, delayMax:5} ==" echo "== 10. PATCH невалидных клампов повторно {archiveAfterDays:99, delayMin:700, delayMax:5} =="
echo " ожидаем archiveAfterDays:30, delay {5,600}" echo " ожидаем archiveAfterDays:30, delay {5,600}"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"archiveAfterDays":99,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT" -d '{"archiveAfterDays":99,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "кламп 30" '"archiveAfterDays":30' check "кламп 30" '"archiveAfterDays":30'
check "delay {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600' check "delay {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
echo echo
echo "== 11. PATCH неизвестного ключа {\"foo\":1} — мягкая семантика, без ошибки, без foo ==" echo "== 11. PATCH неизвестного ключа {\"foo\":1} — мягкая семантика, без ошибки, без foo =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"foo":1}' > "$OUT" -H "Content-Type: application/json" -d '{"foo":1}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200 без detail" '[HTTP:200]' check "PATCH 200 без detail" '[HTTP:200]'
if grep -qF '"foo"' "$OUT"; then if grep -qF '"foo"' "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] foo попал в снимок" echo " [FAIL] foo попал в снимок"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] foo отсутствует в снимке" echo " [PASS] foo отсутствует в снимке"
fi fi
echo echo
echo "== 12. POST /api/auth/logout ==" echo "== 12. POST /api/auth/logout =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
echo echo
echo "== 13. psql: строки settings в схеме дефолтного тенанта; aiConfigs/tgKeys зашифрованы enc: ==" echo "== 13. psql: строки settings в схеме дефолтного тенанта; aiConfigs/tgKeys зашифрованы enc: =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";" $PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
echo "--- проверка enc: в секретах ---" echo "--- проверка enc: в секретах ---"
$PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';" $PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';"
echo echo
echo echo
echo "== 14. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию; psql-свидетельство выше) ==" echo "== 14. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию; psql-свидетельство выше) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1,109 +1,109 @@
# Task 5 — «Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка» — отчёт # Task 5 — «Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (без изменений количества); Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (без изменений количества);
curl-приёмка на :5080 — **PASS=42 FAIL=0** (скрипт `task-5-curl-acceptance.sh`, лог `task-5-curl-acceptance.log`). curl-приёмка на :5080 — **PASS=42 FAIL=0** (скрипт `task-5-curl-acceptance.sh`, лог `task-5-curl-acceptance.log`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 5 L238263, Rulings 3/8/11; Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 5 L238263, Rulings 3/8/11;
эталон — `AuthEndpoints.cs`). Приёмка delay-клампов — **{5,600}**, не {5,700} из плана (опечатка плана; эталон — `AuthEndpoints.cs`). Приёмка delay-клампов — **{5,600}**, не {5,700} из плана (опечатка плана;
клампы 5..600 ПЕРЕД swap — см. ledger/отчёт T3). клампы 5..600 ПЕРЕД swap — см. ledger/отчёт T3).
## Файлы ## Файлы
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `src/core/Deal.Api/Program.cs` | modify | Регистрация scoped `TenantDbContext` (см. ниже); `AddSettingsModule()`; `app.MapSettingsEndpoints()`. | | `src/core/Deal.Api/Program.cs` | modify | Регистрация scoped `TenantDbContext` (см. ниже); `AddSettingsModule()`; `app.MapSettingsEndpoints()`. |
| `src/core/Deal.Api/Endpoints/SettingsEndpoints.cs` | class | `MapSettingsEndpoints`: группа `/api`, tag `settings`, GET/PATCH `/settings` (стиль `AuthEndpoints`). | | `src/core/Deal.Api/Endpoints/SettingsEndpoints.cs` | class | `MapSettingsEndpoints`: группа `/api`, tag `settings`, GET/PATCH `/settings` (стиль `AuthEndpoints`). |
| `.superpowers/sdd/deal-stage2-settings/task-5-curl-acceptance.sh` | sh | curl-приёмка (42 проверки, см. ниже). | | `.superpowers/sdd/deal-stage2-settings/task-5-curl-acceptance.sh` | sh | curl-приёмка (42 проверки, см. ниже). |
## Регистрация TenantDbContext (Task 5, п.1) ## Регистрация TenantDbContext (Task 5, п.1)
В `Program.cs` рядом с `DealDbContext`/`ITenantContext`/`ConnectionStringProvider` добавлено: В `Program.cs` рядом с `DealDbContext`/`ITenantContext`/`ConnectionStringProvider` добавлено:
- `AddDbContext<TenantDbContext>((sp, options) => …)` с `contextLifetime/optionsLifetime: Scoped`. - `AddDbContext<TenantDbContext>((sp, options) => …)` с `contextLifetime/optionsLifetime: Scoped`.
**optionsLifetime: Scoped** обязателен: при singleton-опциях строка подключения первого запроса **optionsLifetime: Scoped** обязателен: при singleton-опциях строка подключения первого запроса
(с Search Path его тенанта) закешировалась бы на весь процесс. Опции строятся на каждый scope по (с Search Path его тенанта) закешировалась бы на весь процесс. Опции строятся на каждый scope по
текущему `ITenantContext` (заполняет `SessionMiddleware`) → `ConnectionStringProvider.ForTenant(TenantId)`. текущему `ITenantContext` (заполняет `SessionMiddleware`) → `ConnectionStringProvider.ForTenant(TenantId)`.
`MigrationsHistoryTable("__TenantMigrationsHistory")` **без схемы** (схема — search_path; runtime-контекст `MigrationsHistoryTable("__TenantMigrationsHistory")` **без схемы** (схема — search_path; runtime-контекст
миграции не выполняет — их применяет `TenantProvisioningService` на старте). миграции не выполняет — их применяет `TenantProvisioningService` на старте).
- Безопасность: `!tenantContext.HasTenant``InvalidOperationException` с понятным сообщением **при резолве** - Безопасность: `!tenantContext.HasTenant``InvalidOperationException` с понятным сообщением **при резолве**
(500 на запрос = ошибка конфигурации; на нормальных запросах без сессии эндпоинт отвечает 401 раньше). (500 на запрос = ошибка конфигурации; на нормальных запросах без сессии эндпоинт отвечает 401 раньше).
## Эндпоинты (Task 5, п.2, контракт §3.4/§4.6) ## Эндпоинты (Task 5, п.2, контракт §3.4/§4.6)
`GET /api/settings` → 200, публичный снимок (дефолты+переопределения, маски Ruling 3, providers). `GET /api/settings` → 200, публичный снимок (дефолты+переопределения, маски Ruling 3, providers).
`PATCH /api/settings` — тело произвольный JSON-объект → `ApplyPatchAsync` → 200, полный снимок после `PATCH /api/settings` — тело произвольный JSON-объект → `ApplyPatchAsync` → 200, полный снимок после
применения (фронт затирает локальный state ответом). 401 `{"detail":"Требуется авторизация"}` без сессии; применения (фронт затирает локальный state ответом). 401 `{"detail":"Требуется авторизация"}` без сессии;
не-JSON-объект тела → 400 `{"detail":"Тело запроса должно быть JSON-объектом"}` (в прототипе FastAPI — 422). не-JSON-объект тела → 400 `{"detail":"Тело запроса должно быть JSON-объектом"}` (в прототипе FastAPI — 422).
Мягкая семантика невалидных полей — из сервиса (Task 3), эндпоинт исключений не добавляет. Мягкая семантика невалидных полей — из сервиса (Task 3), эндпоинт исключений не добавляет.
**Отклонение (важное):** `SettingsService` резолвится из `context.RequestServices` **внутри обработчика после **Отклонение (важное):** `SettingsService` резолвится из `context.RequestServices` **внутри обработчика после
проверки сессии**, а не параметром эндпоинта. DI-биндинг параметров минимальных API выполняется до тела проверки сессии**, а не параметром эндпоинта. DI-биндинг параметров минимальных API выполняется до тела
обработчика, а зависимость сервиса — scoped `TenantDbContext`, опции которого требуют tenant-контекст обработчика, а зависимость сервиса — scoped `TenantDbContext`, опции которого требуют tenant-контекст
(без сессии — не разрешим). При резолве параметром запрос без куки получал бы 500 вместо 401 — (без сессии — не разрешим). При резолве параметром запрос без куки получал бы 500 вместо 401 —
проверено эмпирически на первом прогоне (см. Concerns). Поведение 401 для GET и PATCH подтверждено. проверено эмпирически на первом прогоне (см. Concerns). Поведение 401 для GET и PATCH подтверждено.
## Проверки ## Проверки
### build + unit ### build + unit
``` ```
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0. dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0.
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88 dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88
``` ```
### curl-приёмка (PASS=42 FAIL=0; полный вывод — task-5-curl-acceptance.log) ### curl-приёмка (PASS=42 FAIL=0; полный вывод — task-5-curl-acceptance.log)
1. Очистка `settings` дефолтного тенанта (повторяемость) → GET/PATCH без куки → **401** `Требуется авторизация`; 1. Очистка `settings` дефолтного тенанта (повторяемость) → GET/PATCH без куки → **401** `Требуется авторизация`;
PATCH невалидного JSON с кукой → **400** `Тело запроса должно быть JSON-объектом`. PATCH невалидного JSON с кукой → **400** `Тело запроса должно быть JSON-объектом`.
2. login admin/admin → **GET дефолты**: `autoArchive/aiEnabled/mlEnabled:true`, `archiveAfterDays:14, minLen:24`, 2. login admin/admin → **GET дефолты**: `autoArchive/aiEnabled/mlEnabled:true`, `archiveAfterDays:14, minLen:24`,
`stopPhrases` 4 дефолтные, `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`, `stopPhrases` 4 дефолтные, `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`,
`tgKeys {apiId:"", apiHashSet:false}`, `colState:{}`, aiConfigs keySet:false/keyMasked:"", **providers: 7**. `tgKeys {apiId:"", apiHashSet:false}`, `colState:{}`, aiConfigs keySet:false/keyMasked:"", **providers: 7**.
3. `PATCH {archiveAfterDays:99, minLen:3, discJoinDelayMin:700, discJoinDelayMax:5}` 3. `PATCH {archiveAfterDays:99, minLen:3, discJoinDelayMin:700, discJoinDelayMax:5}`
**`archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:600`** (swap, кламп {5,600}). **`archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:600`** (swap, кламп {5,600}).
4. `PATCH myPrompts [{name:x,prompt:y},{name:"",prompt:""}]` → 1 элемент, **id `pp_…`**. 4. `PATCH myPrompts [{name:x,prompt:y},{name:"",prompt:""}]` → 1 элемент, **id `pp_…`**.
5. `PATCH aiConfigs.deepseek.apiKey sk-1234567890ab`**keySet:true, keyMasked:"sk-1…90ab"**. 5. `PATCH aiConfigs.deepseek.apiKey sk-1234567890ab`**keySet:true, keyMasked:"sk-1…90ab"**.
6. `PATCH tgKeys {apiId:"123456", apiHash:"abcdefghijklmnop"}`**apiId:"123456", apiHashSet:true**. 6. `PATCH tgKeys {apiId:"123456", apiHash:"abcdefghijklmnop"}`**apiId:"123456", apiHashSet:true**.
7. `PATCH {archiveAfterDays:7, minLen:30, stopPhrases:[«стоп раз»,«стоп два»], colState:{review:1,done:2}}` 7. `PATCH {archiveAfterDays:7, minLen:30, stopPhrases:[«стоп раз»,«стоп два»], colState:{review:1,done:2}}`
(тело файлом UTF-8, см. Concerns) → значения применены, colState passthrough как есть. (тело файлом UTF-8, см. Concerns) → значения применены, colState passthrough как есть.
8. **GET после PATCH**: все изменения видны; секреты замаскированы (`sk-1…90ab`), открытого ключа нет; 8. **GET после PATCH**: все изменения видны; секреты замаскированы (`sk-1…90ab`), открытого ключа нет;
keySet/apiHashSet true; myPrompts/colState на месте. keySet/apiHashSet true; myPrompts/colState на месте.
9. Повторный PATCH невалидных клампов `{99, 700/5}`**30 и {5,600}**. 9. Повторный PATCH невалидных клампов `{99, 700/5}`**30 и {5,600}**.
10. `PATCH {foo:1}` → 200 без ошибки, `foo` в снимке отсутствует. → logout 200. 10. `PATCH {foo:1}` → 200 без ошибки, `foo` в снимке отсутствует. → logout 200.
### psql (до очистки; схема `tenant_000…001`) ### psql (до очистки; схема `tenant_000…001`)
``` ```
9 строк: aiConfigs, archiveAfterDays=30, colState, discJoinDelayMin=5, discJoinDelayMax=600, 9 строк: aiConfigs, archiveAfterDays=30, colState, discJoinDelayMin=5, discJoinDelayMax=600,
minLen=30, myPrompts, stopPhrases, tgKeys. enc: — в 2 строках: minLen=30, myPrompts, stopPhrases, tgKeys. enc: — в 2 строках:
aiConfigs → "deepseek":{"apiKey":"enc:5quPTxzr…","baseUrl":… (полный словарь всех провайдеров) aiConfigs → "deepseek":{"apiKey":"enc:5quPTxzr…","baseUrl":… (полный словарь всех провайдеров)
tgKeys → {"apiId":"123456","apiHash":"enc:PKqyPl…"} tgKeys → {"apiId":"123456","apiHash":"enc:PKqyPl…"}
``` ```
После прогона dev-БД возвращена к исходному состоянию: `DELETE FROM …settings` → 0 строк; процесс остановлен, После прогона dev-БД возвращена к исходному состоянию: `DELETE FROM …settings` → 0 строк; процесс остановлен,
порт :5080 свободен, процессов `Deal.Api` нет. порт :5080 свободен, процессов `Deal.Api` нет.
## Отклонения и решения ## Отклонения и решения
1. **Резолв `SettingsService` через `RequestServices` внутри обработчиков** — см. выше; поведение (401 без 1. **Резолв `SettingsService` через `RequestServices` внутри обработчиков** — см. выше; поведение (401 без
сессии, а не 500) зафиксировано в логе прогона-1 и в приёмке. сессии, а не 500) зафиксировано в логе прогона-1 и в приёмке.
2. **`optionsLifetime: Scoped`** для `TenantDbContext` — осознанный выбор multi-tenancy: per-scope строка 2. **`optionsLifetime: Scoped`** для `TenantDbContext` — осознанный выбор multi-tenancy: per-scope строка
подключения (иначе singleton-опции «залипли» бы на первом тенанте). Модель кешируется EF на внутренний подключения (иначе singleton-опции «залипли» бы на первом тенанте). Модель кешируется EF на внутренний
провайдер (ключ — опции), поэтому повторные запросы того же тенанта дешёвые; кол-во тенантов на инсталляцию провайдер (ключ — опции), поэтому повторные запросы того же тенанта дешёвые; кол-во тенантов на инсталляцию
небольшое (dev — 1). небольшое (dev — 1).
3. **Приёмка delay-клампов {5,600}** — план в L257 ожидает `discJoinDelayMax:700` (опечатка); фактическое 3. **Приёмка delay-клампов {5,600}** — план в L257 ожидает `discJoinDelayMax:700` (опечатка); фактическое
поведение (клампы 5..600 перед swap, референс `settings_routes.py` L8893) — `{5,600}`, подтверждено в двух поведение (клампы 5..600 перед swap, референс `settings_routes.py` L8893) — `{5,600}`, подтверждено в двух
шагах приёмки и psql (`discJoinDelayMin=5, discJoinDelayMax=600`). шагах приёмки и psql (`discJoinDelayMin=5, discJoinDelayMax=600`).
4. **Тело с кириллицей в curl** передаётся файлом UTF-8 (`--data-binary @file`): в Windows curl конвертирует 4. **Тело с кириллицей в curl** передаётся файлом UTF-8 (`--data-binary @file`): в Windows curl конвертирует
аргумент командной строки с кириллицей в cp1251 (`[F1]` вместо UTF-8) — сервер получал невалидный UTF-8 и аргумент командной строки с кириллицей в cp1251 (`[F1]` вместо UTF-8) — сервер получал невалидный UTF-8 и
падал 500 (`DecoderFallbackException` в `JsonElement.GetString`). Артефакт приёмочного скрипта, не API: падал 500 (`DecoderFallbackException` в `JsonElement.GetString`). Артефакт приёмочного скрипта, не API:
реальные клиенты (Vue) шлют UTF-8. Наблюдение: API не валидирует UTF-8 строк тела явно (вне приёмки плана). реальные клиенты (Vue) шлют UTF-8. Наблюдение: API не валидирует UTF-8 строк тела явно (вне приёмки плана).
5. **400 для не-JSON-объекта** — план жёстких ошибок тела не специфицирует («Ошибок-исключений нет» относится 5. **400 для не-JSON-объекта** — план жёстких ошибок тела не специфицирует («Ошибок-исключений нет» относится
к полям); 400+detail — минимальная жёсткая граница протокола (в прототипе FastAPI на такое тело — 422). к полям); 400+detail — минимальная жёсткая граница протокола (в прототипе FastAPI на такое тело — 422).
## Concerns ## Concerns
- `SettingsEndpoints` резолвит scoped-сервис через `RequestServices` — отклонение от стиля `AuthEndpoints` - `SettingsEndpoints` резолвит scoped-сервис через `RequestServices` — отклонение от стиля `AuthEndpoints`
(инъекция параметром), но там зависимость на системном `DealDbContext` и резолв вне сессии безопасен. (инъекция параметром), но там зависимость на системном `DealDbContext` и резолв вне сессии безопасен.
Альтернатива (endpoint-filter до биндинга параметров) не гарантирует порядок «фильтр до резолва DI» — не стал Альтернатива (endpoint-filter до биндинга параметров) не гарантирует порядок «фильтр до резолва DI» — не стал
полагаться на недокументированное поведение. полагаться на недокументированное поведение.
- Невалидный UTF-8 в строках PATCH-тела даёт 500 (STJ валидирует строки лениво, `DecoderFallbackException` - Невалидный UTF-8 в строках PATCH-тела даёт 500 (STJ валидирует строки лениво, `DecoderFallbackException`
ловится не как `JsonException`). Вне acceptance; при желании — ловить в `ApplyPatchAsync`/валидировать тело. ловится не как `JsonException`). Вне acceptance; при желании — ловить в `ApplyPatchAsync`/валидировать тело.
@@ -1,204 +1,204 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 6 curl-приёмка POST /api/ai/check на :5080 (план Task 6 L282284, Ruling 7). # Task 6 curl-приёмка POST /api/ai/check на :5080 (план Task 6 L282284, Ruling 7).
# Сценарий: 401 без куки → login admin/admin → без ключа (дефолт deepseek) → «Не задан API-ключ» → # Сценарий: 401 без куки → login admin/admin → без ключа (дефолт deepseek) → «Не задан API-ключ» →
# PATCH deepseek {baseUrl: http://127.0.0.1:59999 (недоступный порт), apiKey} → «Ошибка соединения» → # PATCH deepseek {baseUrl: http://127.0.0.1:59999 (недоступный порт), apiKey} → «Ошибка соединения» →
# aiProvider=ollama (локальный) → «Локальный сервер …» → SSRF-гейт: ftp-схема baseUrl → ok:false → # aiProvider=ollama (локальный) → «Локальный сервер …» → SSRF-гейт: ftp-схема baseUrl → ok:false →
# logout → psql: enc: в aiConfigs. Вывод всех шагов в stdout. # logout → psql: enc: в aiConfigs. Вывод всех шагов в stdout.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
CORE_DIR="C:/telbase/src/core" CORE_DIR="C:/telbase/src/core"
API_DIR="$CORE_DIR/Deal.Api" API_DIR="$CORE_DIR/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task6-jar.txt" JAR="/tmp/task6-jar.txt"
OUT="/tmp/task6-out.txt" OUT="/tmp/task6-out.txt"
LOG="/tmp/task6-api.log" LOG="/tmp/task6-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторяемость). # Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторяемость).
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. POST /api/ai/check без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} ==" echo "== 1. POST /api/ai/check без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/check" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. POST /api/ai/check без ключа (дефолт: deepseek) — ok:false «Не задан API-ключ» ==" echo "== 3. POST /api/ai/check без ключа (дефолт: deepseek) — ok:false «Не задан API-ключ» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "без ключа" '"ok":false' '"message":"Не задан API-ключ"' check "без ключа" '"ok":false' '"message":"Не задан API-ключ"'
check "статус deepseek (дефолты)" '"provider":"deepseek"' '"name":"DeepSeek"' check "статус deepseek (дефолты)" '"provider":"deepseek"' '"name":"DeepSeek"'
check "base/model дефолтные" '"base":"https://api.deepseek.com"' '"model":"deepseek-v4-flash"' check "base/model дефолтные" '"base":"https://api.deepseek.com"' '"model":"deepseek-v4-flash"'
check "keySet false / маска пуста" '"keySet":false' '"keyMasked":""' check "keySet false / маска пуста" '"keySet":false' '"keyMasked":""'
check "local false" '"local":false' check "local false" '"local":false'
echo echo
echo "== 4. PATCH deepseek {baseUrl: http://127.0.0.1:59999, apiKey: sk-1234567890ab} ==" echo "== 4. PATCH deepseek {baseUrl: http://127.0.0.1:59999, apiKey: sk-1234567890ab} =="
echo " недоступный порт — ветка сетевого сбоя детерминирована (без внешней сети)" echo " недоступный порт — ветка сетевого сбоя детерминирована (без внешней сети)"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"aiConfigs":{"deepseek":{"baseUrl":"http://127.0.0.1:59999","apiKey":"sk-1234567890ab"}}}' > "$OUT" -d '{"aiConfigs":{"deepseek":{"baseUrl":"http://127.0.0.1:59999","apiKey":"sk-1234567890ab"}}}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, keySet+маска" '[HTTP:200]' '"keySet":true' '"keyMasked":"sk-1…90ab"' check "PATCH 200, keySet+маска" '[HTTP:200]' '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo echo
echo "== 4b. POST /api/ai/check — глубокое подключение недоступно → «Ошибка соединения» ==" echo "== 4b. POST /api/ai/check — глубокое подключение недоступно → «Ошибка соединения» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "check 200" '[HTTP:200]' check "check 200" '[HTTP:200]'
check "ошибка соединения (префикс)" '"ok":false' '"message":"Ошибка соединения:' check "ошибка соединения (префикс)" '"ok":false' '"message":"Ошибка соединения:'
check "base из конфигурации" '"base":"http://127.0.0.1:59999"' check "base из конфигурации" '"base":"http://127.0.0.1:59999"'
check "ключ задан и замаскирован" '"keySet":true' '"keyMasked":"sk-1…90ab"' check "ключ задан и замаскирован" '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo echo
echo "== 5. PATCH aiProvider=ollama → POST /api/ai/check — локальный провайдер, ok:true ==" echo "== 5. PATCH aiProvider=ollama → POST /api/ai/check — локальный провайдер, ok:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"aiProvider":"ollama"}' > "$OUT" -H "Content-Type: application/json" -d '{"aiProvider":"ollama"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, aiProvider ollama" '[HTTP:200]' '"aiProvider":"ollama"' check "PATCH 200, aiProvider ollama" '[HTTP:200]' '"aiProvider":"ollama"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "локальный провайдер ok" '[HTTP:200]' '"ok":true' check "локальный провайдер ok" '[HTTP:200]' '"ok":true'
check "сообщение локального сервера" '"message":"Локальный сервер «Ollama (локально)» (ping в проде)"' check "сообщение локального сервера" '"message":"Локальный сервер «Ollama (локально)» (ping в проде)"'
check "статус ollama" '"provider":"ollama"' '"local":true' check "статус ollama" '"provider":"ollama"' '"local":true'
echo echo
echo "== 6. PATCH deepseek baseUrl ftp://example.com (SSRF-гейт схемы) → ok:false ==" echo "== 6. PATCH deepseek baseUrl ftp://example.com (SSRF-гейт схемы) → ok:false =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"aiProvider":"deepseek","aiConfigs":{"deepseek":{"baseUrl":"ftp://example.com"}}}' > "$OUT" -d '{"aiProvider":"deepseek","aiConfigs":{"deepseek":{"baseUrl":"ftp://example.com"}}}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, aiProvider deepseek" '[HTTP:200]' '"aiProvider":"deepseek"' check "PATCH 200, aiProvider deepseek" '[HTTP:200]' '"aiProvider":"deepseek"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "недопустимая схема base URL" '[HTTP:200]' '"ok":false' '"message":"Недопустимый Base URL (ожидается http/https)"' check "недопустимая схема base URL" '[HTTP:200]' '"ok":false' '"message":"Недопустимый Base URL (ожидается http/https)"'
echo echo
echo "== 7. POST /api/auth/logout ==" echo "== 7. POST /api/auth/logout =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
echo echo
echo "== 8. psql: строки settings; aiConfigs зашифрован (enc:) без открытого ключа ==" echo "== 8. psql: строки settings; aiConfigs зашифрован (enc:) без открытого ключа =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";" $PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
echo "--- проверка enc: ---" echo "--- проверка enc: ---"
ENC_KEYS=$($PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';") ENC_KEYS=$($PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';")
echo "enc: найдено в: $ENC_KEYS" echo "enc: найдено в: $ENC_KEYS"
if echo "$ENC_KEYS" | grep -q "aiConfigs"; then if echo "$ENC_KEYS" | grep -q "aiConfigs"; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] aiConfigs хранит ключ в формате enc:" echo " [PASS] aiConfigs хранит ключ в формате enc:"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] aiConfigs без enc:" echo " [FAIL] aiConfigs без enc:"
fi fi
if $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'aiConfigs';" | grep -qF "sk-1234567890ab"; then if $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'aiConfigs';" | grep -qF "sk-1234567890ab"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] открытый ключ sk-1234567890ab найден в БД" echo " [FAIL] открытый ключ sk-1234567890ab найден в БД"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] открытого ключа в БД нет (только enc:)" echo " [PASS] открытого ключа в БД нет (только enc:)"
fi fi
echo echo
echo "== 9. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 9. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1,106 +1,106 @@
# Task 6 — «ИИ-провайдеры и POST /api/ai/check (проверка подключения)» — отчёт # Task 6 — «ИИ-провайдеры и POST /api/ai/check (проверка подключения)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты **98/98 PASS** (+10 новых Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты **98/98 PASS** (+10 новых
`AiConnectionCheckerTests`); curl-приёмка на :5080 — **PASS=23 FAIL=0** (скрипт `AiConnectionCheckerTests`); curl-приёмка на :5080 — **PASS=23 FAIL=0** (скрипт
`task-6-curl-acceptance.sh`, лог `task-6-curl-acceptance.log`). Отчёт по плану `task-6-curl-acceptance.sh`, лог `task-6-curl-acceptance.log`). Отчёт по плану
`docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 6 L265285, Rulings 4/7/8; `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 6 L265285, Rulings 4/7/8;
референс `settings_routes.py` L195219, `ai.py` L36–58). После прогона dev-БД очищена, порт :5080 референс `settings_routes.py` L195219, `ai.py` L36–58). После прогона dev-БД очищена, порт :5080
свободен. свободен.
## Файлы ## Файлы
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs` | interface | Модульный порт (Ruling 4): `Task<AiCheckResultDto> CheckAsync(AiCheckRequest, ct)`. | | `src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs` | interface | Модульный порт (Ruling 4): `Task<AiCheckResultDto> CheckAsync(AiCheckRequest, ct)`. |
| `src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs` | record | `{ProviderId, BaseUrl, Model, ApiKey, IsLocal, ApiStyle}` (поля — по плану Task 6). | | `src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs` | record | `{ProviderId, BaseUrl, Model, ApiKey, IsLocal, ApiStyle}` (поля — по плану Task 6). |
| `src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs` | record | `{Ok, Message, Provider, Name, Base, Model, Local, KeySet, KeyMasked}` (наружу camelCase). | | `src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs` | record | `{Ok, Message, Provider, Name, Base, Model, Local, KeySet, KeyMasked}` (наружу camelCase). |
| `src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs` | class | HTTP-реализация (Ruling 7): ветки 1:1 с `ai_check`, таймаут 12 с, маска `mask_key`. | | `src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs` | class | HTTP-реализация (Ruling 7): ветки 1:1 с `ai_check`, таймаут 12 с, маска `mask_key`. |
| `src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs` | class | `MapAiCheckEndpoint` → POST `/api/ai/check`; 401-гейт; резолв RequestServices; чтение активного конфига провайдера (`ISettingsStore`+`ISecretCipher`). | | `src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs` | class | `MapAiCheckEndpoint` → POST `/api/ai/check`; 401-гейт; резолв RequestServices; чтение активного конфига провайдера (`ISettingsStore`+`ISecretCipher`). |
| `src/core/Deal.Api/Http/EndpointResults.cs` | class | Общий HTTP-хелпер 401/400 `{detail}` (note T5-ревью «при 3-м Endpoints-файле»); Auth/Settings эндпоинты переведены на него. | | `src/core/Deal.Api/Http/EndpointResults.cs` | class | Общий HTTP-хелпер 401/400 `{detail}` (note T5-ревью «при 3-м Endpoints-файле»); Auth/Settings эндпоинты переведены на него. |
| `src/core/Deal.Api/Program.cs` | modify | `AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(12 с)`; `app.MapAiCheckEndpoint()`. | | `src/core/Deal.Api/Program.cs` | modify | `AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(12 с)`; `app.MapAiCheckEndpoint()`. |
| `src/core/tests/Deal.Tests.Unit/AiConnectionCheckerTests.cs` | test | Ветки: без ключа; local; 200 (+URL/Bearer/маска/статус); 401/403; HTTP 500; сетевая ошибка; Anthropic `/v1/models`+`x-api-key`; SSRF-гейты (неизвестный провайдер, не-http(s) base). | | `src/core/tests/Deal.Tests.Unit/AiConnectionCheckerTests.cs` | test | Ветки: без ключа; local; 200 (+URL/Bearer/маска/статус); 401/403; HTTP 500; сетевая ошибка; Anthropic `/v1/models`+`x-api-key`; SSRF-гейты (неизвестный провайдер, не-http(s) base). |
| `src/core/tests/Deal.Tests.Unit/StubHttpMessageHandler.cs` | test-double | Fake `HttpMessageHandler` (запись запросов, ответ/исключение по делегату). | | `src/core/tests/Deal.Tests.Unit/StubHttpMessageHandler.cs` | test-double | Fake `HttpMessageHandler` (запись запросов, ответ/исключение по делегату). |
| `.superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh`/`.log` | sh/log | curl-приёмка (23 проверки). | | `.superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh`/`.log` | sh/log | curl-приёмка (23 проверки). |
## Решения ## Решения
1. **Дизайн порта (Ruling 4).** `IAiConnectionChecker` — в модуле Settings (Application), потребляется 1. **Дизайн порта (Ruling 4).** `IAiConnectionChecker` — в модуле Settings (Application), потребляется
только Settings-экраном; `IAiFacade` не заводится. Реализация — в Infrastructure/Integrations, только Settings-экраном; `IAiFacade` не заводится. Реализация — в Infrastructure/Integrations,
ctor принимает `HttpClient`; DI — typed client `AddHttpClient<IAiConnectionChecker, ctor принимает `HttpClient`; DI — typed client `AddHttpClient<IAiConnectionChecker,
AiConnectionChecker>` в `Program.cs` (фабрика, таймаут `RequestTimeoutSeconds = 12` с). Unit-тесты AiConnectionChecker>` в `Program.cs` (фабрика, таймаут `RequestTimeoutSeconds = 12` с). Unit-тесты
строят адаптер напрямую `new AiConnectionChecker(new HttpClient(stub))`. строят адаптер напрямую `new AiConnectionChecker(new HttpClient(stub))`.
2. **Ветки 1:1** с `settings_routes.py` L195–219 (порядок кода прототипа: **локальный → ключ → HTTP**; 2. **Ветки 1:1** с `settings_routes.py` L195–219 (порядок кода прототипа: **локальный → ключ → HTTP**;
иначе приёмка ollama без ключа дала бы «Не задан API-ключ» вместо «Локальный сервер…»): иначе приёмка ollama без ключа дала бы «Не задан API-ключ» вместо «Локальный сервер…»):
- локальный → `ok:true` «Локальный сервер «<name>» (ping в проде)» (HTTP не ходим); - локальный → `ok:true` «Локальный сервер «<name>» (ping в проде)» (HTTP не ходим);
- нет ключа → `ok:false` «Не задан API-ключ»; - нет ключа → `ok:false` «Не задан API-ключ»;
- HTTP `<400` → «Подключение успешно»; `401/403` → «Ключ не принят (HTTP n) — проверьте ключ и - HTTP `<400` → «Подключение успешно»; `401/403` → «Ключ не принят (HTTP n) — проверьте ключ и
доступ к модели»; иное (≥400) → «HTTP n — проверьте Base URL и модель»; таймаут/сеть → доступ к модели»; иное (≥400) → «HTTP n — проверьте Base URL и модель»; таймаут/сеть →
«Ошибка соединения: …». URL: `{base}/models`, Anthropic — `{base}/v1/models` + `x-api-key` + «Ошибка соединения: …». URL: `{base}/models`, Anthropic — `{base}/v1/models` + `x-api-key` +
`anthropic-version` (Bearer для OpenAI-совместимых). `base` rstrip("/") как в прототипе. `anthropic-version` (Bearer для OpenAI-совместимых). `base` rstrip("/") как в прототипе.
3. **`keyMasked` — маска `ai.py` mask_key L5358** (источник Ruling 7 указывает на mask_key): пусто → 3. **`keyMasked` — маска `ai.py` mask_key L5358** (источник Ruling 7 указывает на mask_key): пусто →
`""`, len ≤ 8 → «x…», иначе «1234…5678». Отличие от маски Ruling 3 (len ≤ 8 — как есть) только для `""`, len ≤ 8 → «x…», иначе «1234…5678». Отличие от маски Ruling 3 (len ≤ 8 — как есть) только для
ключей ≤ 8 симв.; PATCH принимает ключи ≥ 8 — на практике расхождения нет (зафиксировано). ключей ≤ 8 симв.; PATCH принимает ключи ≥ 8 — на практике расхождения нет (зафиксировано).
4. **SSRF (см. preflight):** реализован allowlist **провайдеров** — checker отклоняет id вне 4. **SSRF (см. preflight):** реализован allowlist **провайдеров** — checker отклоняет id вне
фиксированного каталога `AiProviders` (ok:false «Провайдер не из списка разрешённых», HTTP не фиксированного каталога `AiProviders` (ok:false «Провайдер не из списка разрешённых», HTTP не
выполняется; в штатном потоке недостижимо — PATCH-гейт `aiProvider`/`aiConfigs`) + гейт схемы base выполняется; в штатном потоке недостижимо — PATCH-гейт `aiProvider`/`aiConfigs`) + гейт схемы base
URL: только абсолютный `http/https` (иначе ok:false «Недопустимый Base URL (ожидается http/https)»). URL: только абсолютный `http/https` (иначе ok:false «Недопустимый Base URL (ожидается http/https)»).
Host-level рестрикции (совпадение baseUrl с дефолтом провайдера) **сознательно не вводил**: это Host-level рестрикции (совпадение baseUrl с дефолтом провайдера) **сознательно не вводил**: это
сломало бы локальные серверы на LAN (Ollama/LM Studio), кастомные OpenAI-совместимые эндпоинты и сломало бы локальные серверы на LAN (Ollama/LM Studio), кастомные OpenAI-совместимые эндпоинты и
ветку приёмки плана «недоступный хост → Ошибка соединения» (требует произвольного http(s)-хоста в ветку приёмки плана «недоступный хост → Ошибка соединения» (требует произвольного http(s)-хоста в
конфиге). Триггер — только авторизованный владелец настроек; запрос — один GET /models. конфиге). Триггер — только авторизованный владелец настроек; запрос — один GET /models.
Продуктовая жёсткость (egress-фильтр/разрешённые хосты, проверка private-IP) — зафиксирована на Продуктовая жёсткость (egress-фильтр/разрешённые хосты, проверка private-IP) — зафиксирована на
прод-этап (за Cloudflare/шлюзом). Решение зафиксировано в `AiConnectionChecker` (XML-doc). прод-этап (за Cloudflare/шлюзом). Решение зафиксировано в `AiConnectionChecker` (XML-doc).
5. **Эндпоинт без тела** (фронт `store.js checkAiConnection``POST /api/ai/check` без body): сервер 5. **Эндпоинт без тела** (фронт `store.js checkAiConnection``POST /api/ai/check` без body): сервер
читает **активную** конфигурацию (aiProvider + aiConfigs) из `ISettingsStore` и расшифровывает ключ читает **активную** конфигурацию (aiProvider + aiConfigs) из `ISettingsStore` и расшифровывает ключ
`ISecretCipher` (1:1 с `ai_svc._cfg()`), как предписано планом; сборка запроса — приватный хелпер `ISecretCipher` (1:1 с `ai_svc._cfg()`), как предписано планом; сборка запроса — приватный хелпер
эндпоинта (дефолты `SettingsDefaults` + переопределения). Резолв scoped-зависимостей — через эндпоинта (дефолты `SettingsDefaults` + переопределения). Резолв scoped-зависимостей — через
`RequestServices` **после** 401-гейта (паттерн SettingsEndpoints: иначе запрос без сессии — 500). `RequestServices` **после** 401-гейта (паттерн SettingsEndpoints: иначе запрос без сессии — 500).
6. **Общий HTTP-хелпер `EndpointResults`** (401/400 `{detail}`): третья Endpoints-файла — по заметке 6. **Общий HTTP-хелпер `EndpointResults`** (401/400 `{detail}`): третья Endpoints-файла — по заметке
ревью T5; AuthEndpoints/SettingsEndpoints переведены, поведение не менялось (curl-приёмка T5-шагов ревью T5; AuthEndpoints/SettingsEndpoints переведены, поведение не менялось (curl-приёмка T5-шагов
воспроизводится в логе T6 шагов 1–2). воспроизводится в логе T6 шагов 1–2).
## Отклонения от задания/плана ## Отклонения от задания/плана
- Имя файла эндпоинта: задание задачи говорит `Endpoints/AiEndpoints.cs`, план (Task 6 Files) — - Имя файла эндпоинта: задание задачи говорит `Endpoints/AiEndpoints.cs`, план (Task 6 Files) —
`AiCheckEndpoint.cs`. **План приоритетнее** — файл `AiCheckEndpoint.cs`, метод `MapAiCheckEndpoint`. `AiCheckEndpoint.cs`. **План приоритетнее** — файл `AiCheckEndpoint.cs`, метод `MapAiCheckEndpoint`.
- Файл `RatesEndpoints`/`AiCheckEndpoint` tag: `/ai/check` в прототипе живёт в роутере settings - Файл `RatesEndpoints`/`AiCheckEndpoint` tag: `/ai/check` в прототипе живёт в роутере settings
(tags=["settings"]) — tag `settings` (как в api-map §3.4). (tags=["settings"]) — tag `settings` (как в api-map §3.4).
- В `progress.md` preflight T6/T7 отмечен SSRF-риск — закрыт в объёме dev-режима (см. п.4). - В `progress.md` preflight T6/T7 отмечен SSRF-риск — закрыт в объёме dev-режима (см. п.4).
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0 dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
dotnet test tests/Deal.Tests.Unit → всего: 98; сбой: 0; успешно: 98 dotnet test tests/Deal.Tests.Unit → всего: 98; сбой: 0; успешно: 98
sh .superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh sh .superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh
→ PASS=23 FAIL=0 (лог task-6-curl-acceptance.log) → PASS=23 FAIL=0 (лог task-6-curl-acceptance.log)
``` ```
### curl-приёмка (сценарий и фактические ответы — в логе) ### curl-приёмка (сценарий и фактические ответы — в логе)
1. `POST /api/ai/check` без куки → **401** `{"detail":"Требуется авторизация"}`. 1. `POST /api/ai/check` без куки → **401** `{"detail":"Требуется авторизация"}`.
2. login admin/admin → без ключа (дефолт deepseek) → **200** `{"ok":false,"message":"Не задан 2. login admin/admin → без ключа (дефолт deepseek) → **200** `{"ok":false,"message":"Не задан
API-ключ","provider":"deepseek","name":"DeepSeek","base":"https://api.deepseek.com", API-ключ","provider":"deepseek","name":"DeepSeek","base":"https://api.deepseek.com",
"model":"deepseek-v4-flash","local":false,"keySet":false,"keyMasked":""}`. "model":"deepseek-v4-flash","local":false,"keySet":false,"keyMasked":""}`.
3. `PATCH aiConfigs.deepseek {baseUrl:"http://127.0.0.1:59999", apiKey:"sk-1234567890ab"}` (недоступный 3. `PATCH aiConfigs.deepseek {baseUrl:"http://127.0.0.1:59999", apiKey:"sk-1234567890ab"}` (недоступный
порт — сетевой сбой детерминирован, без внешней сети) → check → **200** `{"ok":false,"message": порт — сетевой сбой детерминирован, без внешней сети) → check → **200** `{"ok":false,"message":
"Ошибка соединения: Подключение не установлено, т.к. конечный компьютер отверг запрос на "Ошибка соединения: Подключение не установлено, т.к. конечный компьютер отверг запрос на
подключение. (127.0.0.1:59999)",…,"base":"http://127.0.0.1:59999","keySet":true, подключение. (127.0.0.1:59999)",…,"base":"http://127.0.0.1:59999","keySet":true,
"keyMasked":"sk-1…90ab"}`. "keyMasked":"sk-1…90ab"}`.
4. `PATCH aiProvider=ollama` → check → **200** `{"ok":true,"message":"Локальный сервер «Ollama 4. `PATCH aiProvider=ollama` → check → **200** `{"ok":true,"message":"Локальный сервер «Ollama
(локально)» (ping в проде)","provider":"ollama",…,"local":true}`. (локально)» (ping в проде)","provider":"ollama",…,"local":true}`.
5. `PATCH aiProvider=deepseek + baseUrl:"ftp://example.com"` → check → **200** ok:false 5. `PATCH aiProvider=deepseek + baseUrl:"ftp://example.com"` → check → **200** ok:false
«Недопустимый Base URL (ожидается http/https)» (SSRF-гейт схемы). «Недопустимый Base URL (ожидается http/https)» (SSRF-гейт схемы).
6. logout 200. psql до очистки: строки `aiConfigs`/`aiProvider`; apiKey в `aiConfigs``enc:…`, 6. logout 200. psql до очистки: строки `aiConfigs`/`aiProvider`; apiKey в `aiConfigs``enc:…`,
открытого ключа в БД нет. После прогона таблица `settings` очищена (0 строк), процесс остановлен. открытого ключа в БД нет. После прогона таблица `settings` очищена (0 строк), процесс остановлен.
## Concerns ## Concerns
- Модуль не узнаёт имя провайдера из запроса: checker резолвит `Name` по каталогу `AiProviders` - Модуль не узнаёт имя провайдера из запроса: checker резолвит `Name` по каталогу `AiProviders`
(в запросе имени нет — поля DTO заданы планом). Для неизвестного id (ручное вмешательство в БД) (в запросе имени нет — поля DTO заданы планом). Для неизвестного id (ручное вмешательство в БД)
`Name` = id; в штатном потоке недостижимо. `Name` = id; в штатном потоке недостижимо.
- Ветка таймаута не покрыта unit-тестом (требует реальной задержки); проверена логикой - Ветка таймаута не покрыта unit-тестом (требует реальной задержки); проверена логикой
`catch (OperationCanceledException) when (!ct.IsCancellationRequested)`. «Ошибка соединения» на `catch (OperationCanceledException) when (!ct.IsCancellationRequested)`. «Ошибка соединения» на
timeout соответствует прототипу (ловит все исключения). timeout соответствует прототипу (ловит все исключения).
- Расхождение маски короткого ключа (≤8) между `/api/ai/check` (mask_key: «x…») и GET/PATCH settings - Расхождение маски короткого ключа (≤8) между `/api/ai/check` (mask_key: «x…») и GET/PATCH settings
(Ruling 3: как есть) — см. п.3 «Решений»; влияет только на ключи ≤ 8 симв., которых PATCH не создаёт. (Ruling 3: как есть) — см. п.3 «Решений»; влияет только на ключи ≤ 8 симв., которых PATCH не создаёт.
@@ -1,204 +1,204 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 7 curl-приёмка границы промптов на :5080 (план Task 7 L303306). # Task 7 curl-приёмка границы промптов на :5080 (план Task 7 L303306).
# Сценарий: 401 без куки → login admin/admin → GET: дефолтный aiPrompt с {domain}/{keywords}, # Сценарий: 401 без куки → login admin/admin → GET: дефолтный aiPrompt с {domain}/{keywords},
# myPrompts пуст → PATCH aiPrompt с плейсхолдерами → GET возвращает тот же текст → # myPrompts пуст → PATCH aiPrompt с плейсхолдерами → GET возвращает тот же текст →
# PATCH myPrompts 3 записи (id/name/description/prompt) → GET отдаёт их (camelCase) → # PATCH myPrompts 3 записи (id/name/description/prompt) → GET отдаёт их (camelCase) →
# psql: строки settings → logout → GET после logout = 401. Вывод всех шагов в stdout. # psql: строки settings → logout → GET после logout = 401. Вывод всех шагов в stdout.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task7-jar.txt" JAR="/tmp/task7-jar.txt"
OUT="/tmp/task7-out.txt" OUT="/tmp/task7-out.txt"
LOG="/tmp/task7-api.log" LOG="/tmp/task7-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PROMPT_AI="ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли" PROMPT_AI="ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"
PAY_AI="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-ai.json" PAY_AI="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-ai.json"
PAY_MY="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-myprompts.json" PAY_MY="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-myprompts.json"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
check_count() { check_count() {
# $1 — описание; $2 — ожидаемое число; $3 — подстрока (по одному совпадению на элемент) # $1 — описание; $2 — ожидаемое число; $3 — подстрока (по одному совпадению на элемент)
desc=$1 desc=$1
expected=$2 expected=$2
pattern=$3 pattern=$3
actual=$(grep -o -F -- "$pattern" "$OUT" | wc -l) actual=$(grep -o -F -- "$pattern" "$OUT" | wc -l)
if [ "$actual" = "$expected" ]; then if [ "$actual" = "$expected" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (нашлось $actual)" echo " [PASS] $desc (нашлось $actual)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — ожидалось $expected, нашлось $actual" echo " [FAIL] $desc — ожидалось $expected, нашлось $actual"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. GET /api/settings без куки — ожидаем 401 ==" echo "== 1. GET /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. GET /api/settings — дефолты промптов из data.js, myPrompts пуст ==" echo "== 3. GET /api/settings — дефолты промптов из data.js, myPrompts пуст =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "дефолтный aiPrompt (маркеры data.js)" '"aiPrompt":"Ты — классификатор входящих сообщений' '{domain}' '{keywords}' check "дефолтный aiPrompt (маркеры data.js)" '"aiPrompt":"Ты — классификатор входящих сообщений' '{domain}' '{keywords}'
check "myPrompts пуст" '"myPrompts":[]' check "myPrompts пуст" '"myPrompts":[]'
echo echo
echo "== 4. PATCH aiPrompt с плейсхолдерами {domain}/{keywords} ==" echo "== 4. PATCH aiPrompt с плейсхолдерами {domain}/{keywords} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @"$PAY_AI" > "$OUT" -H "Content-Type: application/json" --data-binary @"$PAY_AI" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, текст на месте" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\"" check "PATCH 200, текст на месте" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
echo echo
echo "== 4b. GET /api/settings — тот же текст промпта (с плейсхолдерами как есть) ==" echo "== 4b. GET /api/settings — тот же текст промпта (с плейсхолдерами как есть) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200, тот же текст" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\"" check "GET 200, тот же текст" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
echo echo
echo "== 5. PATCH myPrompts — 3 записи (id/name/description/prompt) ==" echo "== 5. PATCH myPrompts — 3 записи (id/name/description/prompt) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @"$PAY_MY" > "$OUT" -H "Content-Type: application/json" --data-binary @"$PAY_MY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200" '[HTTP:200]' check "PATCH 200" '[HTTP:200]'
check "все три записи в ответе" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"' check "все три записи в ответе" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
check "поля camelCase" '"name":"Кровля — строгий"' '"description":"Только явные заказы на кровлю"' check "поля camelCase" '"name":"Кровля — строгий"' '"description":"Только явные заказы на кровлю"'
check "текст промпта доехал" '"prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"' check "текст промпта доехал" '"prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"'
echo echo
echo "== 5b. GET /api/settings — «Мои промпты» на месте ==" echo "== 5b. GET /api/settings — «Мои промпты» на месте =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200" '[HTTP:200]' check "GET 200" '[HTTP:200]'
check "три записи" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"' check "три записи" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
check_count "myPrompts содержит 3 элемента" 3 '"id":"pp_' check_count "myPrompts содержит 3 элемента" 3 '"id":"pp_'
check "описания на месте" '"description":"Вакансии разработчиков"' check "описания на месте" '"description":"Вакансии разработчиков"'
echo echo
echo "== 6. psql: строки settings (aiPrompt, myPrompts) ==" echo "== 6. psql: строки settings (aiPrompt, myPrompts) =="
$PSQL_BASE -c "SELECT \"Key\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";" $PSQL_BASE -c "SELECT \"Key\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
MYPROMPTS_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'myPrompts';") MYPROMPTS_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'myPrompts';")
echo "myPrompts value_json: $MYPROMPTS_JSON" echo "myPrompts value_json: $MYPROMPTS_JSON"
if echo "$MYPROMPTS_JSON" | grep -q "pp_aaaa1111"; then if echo "$MYPROMPTS_JSON" | grep -q "pp_aaaa1111"; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] myPrompts сохранены в KV settings" echo " [PASS] myPrompts сохранены в KV settings"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] myPrompts не найдены в KV settings" echo " [FAIL] myPrompts не найдены в KV settings"
fi fi
echo echo
echo "== 7. POST /api/auth/logout, затем GET — 401 ==" echo "== 7. POST /api/auth/logout, затем GET — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 8. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 8. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1 +1 @@
{"aiPrompt":"ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"} {"aiPrompt":"ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"}
@@ -1,5 +1,5 @@
{"myPrompts":[ {"myPrompts":[
{"id":"pp_aaaa1111","name":"Кровля — строгий","description":"Только явные заказы на кровлю","prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"}, {"id":"pp_aaaa1111","name":"Кровля — строгий","description":"Только явные заказы на кровлю","prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"},
{"id":"pp_bbbb2222","name":"Дизайн — гибкий","description":"","prompt":"Ты — классификатор дизайна: {domain}, маркеры: {keywords}"}, {"id":"pp_bbbb2222","name":"Дизайн — гибкий","description":"","prompt":"Ты — классификатор дизайна: {domain}, маркеры: {keywords}"},
{"id":"pp_cccc3333","name":"Найм IT","description":"Вакансии разработчиков","prompt":"Ты — классификатор найма IT: {domain}, маркеры: {keywords}"} {"id":"pp_cccc3333","name":"Найм IT","description":"Вакансии разработчиков","prompt":"Ты — классификатор найма IT: {domain}, маркеры: {keywords}"}
]} ]}
@@ -1,52 +1,52 @@
# Task 7 — «Промпты и „Мои промпты“: интеграционная проверка границы с фронтом» — отчёт # Task 7 — «Промпты и „Мои промпты“: интеграционная проверка границы с фронтом» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 113/113 PASS (было 98, добавлено 15 — `PromptDefaultsTests`); curl-приёмка :5080 — PASS=19 FAIL=0. Статус: **complete**. Build 0 warnings / 0 errors; тесты 113/113 PASS (было 98, добавлено 15 — `PromptDefaultsTests`); curl-приёмка :5080 — PASS=19 FAIL=0.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 7 L287308, Ruling 8; референс `ai.py` L6377). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 7 L287308, Ruling 8; референс `ai.py` L6377).
## Граница «библиотека промптов — фронт» (проверка кода, новых эндпоинтов НЕТ) ## Граница «библиотека промптов — фронт» (проверка кода, новых эндпоинтов НЕТ)
- Библиотека промптов **полностью фронтовая**: `data.js` `PROMPT_LIBRARY` L180200 (19 шаблонов, категории L167–176, готовые тексты вариантов поведения L155–165) + `buildClassifierPrompt` L148151. `PromptLibraryModal.vue` НЕ ходит в API: поиск/категории/превью — локальные `computed`; «Применить в редактор» = `emit('apply')``SettingsView.onApplyLib` пишет только в локальный `state.aiPrompt` (сохранение — отдельной кнопкой «Сохранить промпт» → PATCH `/api/settings`). «В Мои промпты» = `store.addMyPrompt``persistMyPrompts` → PATCH `/api/settings` `{myPrompts}`. - Библиотека промптов **полностью фронтовая**: `data.js` `PROMPT_LIBRARY` L180200 (19 шаблонов, категории L167–176, готовые тексты вариантов поведения L155–165) + `buildClassifierPrompt` L148151. `PromptLibraryModal.vue` НЕ ходит в API: поиск/категории/превью — локальные `computed`; «Применить в редактор» = `emit('apply')``SettingsView.onApplyLib` пишет только в локальный `state.aiPrompt` (сохранение — отдельной кнопкой «Сохранить промпт» → PATCH `/api/settings`). «В Мои промпты» = `store.addMyPrompt``persistMyPrompts` → PATCH `/api/settings` `{myPrompts}`.
- Эндпоинтов `/api/prompts*` в репозитории нет (grep по docs/src — 0 совпадений; api.js — тонкий http-клиент, все пути инлайном в store.js/views). Наружу за границу выходят только строки-промпты (`aiPrompt`/`cardPrompt`/`aiFilterPrompt`) и `myPrompts` — через GET/PATCH `/api/settings`. Ruling 8 подтверждена. - Эндпоинтов `/api/prompts*` в репозитории нет (grep по docs/src — 0 совпадений; api.js — тонкий http-клиент, все пути инлайном в store.js/views). Наружу за границу выходят только строки-промпты (`aiPrompt`/`cardPrompt`/`aiFilterPrompt`) и `myPrompts` — через GET/PATCH `/api/settings`. Ruling 8 подтверждена.
- Формат «Моих промптов» 1:1: фронт шлёт `{id: "pp_…", name, description, prompt}` (store.js L16911705), бэк — `MyPromptDto(Id, Name, Description, Prompt)` → наружу camelCase `id/name/description/prompt`. - Формат «Моих промптов» 1:1: фронт шлёт `{id: "pp_…", name, description, prompt}` (store.js L16911705), бэк — `MyPromptDto(Id, Name, Description, Prompt)` → наружу camelCase `id/name/description/prompt`.
## Сверка DefaultPrompts с data.js (построчно, включая хвостовые \n) ## Сверка DefaultPrompts с data.js (построчно, включая хвостовые \n)
Полная сверка выполнена скриптом `task-7-sverka-prompts.mjs` (извлекает шаблоны из data.js и raw-литералы DefaultPrompts.cs, нормализует `\r\n→\n` и `TrimEnd('\n')`) и **закреплена тестом** `PromptDefaultsTests` (читает data.js из репозитория, сравнивает построчно с сообщением о расхождении). Полная сверка выполнена скриптом `task-7-sverka-prompts.mjs` (извлекает шаблоны из data.js и raw-литералы DefaultPrompts.cs, нормализует `\r\n→\n` и `TrimEnd('\n')`) и **закреплена тестом** `PromptDefaultsTests` (читает data.js из репозитория, сравнивает построчно с сообщением о расхождении).
| Промпт | data.js | DefaultPrompts.cs | Итог | | Промпт | data.js | DefaultPrompts.cs | Итог |
|---|---|---|---| |---|---|---|---|
| `aiPrompt` (DEFAULT_AI_PROMPT L94114) | 21 строка, 2858 симв., без хвостового `\n` | 21 строка, 2858 симв. | идентичны | | `aiPrompt` (DEFAULT_AI_PROMPT L94114) | 21 строка, 2858 симв., без хвостового `\n` | 21 строка, 2858 симв. | идентичны |
| `cardPrompt` (DEFAULT_AI_CARD_PROMPT L116123) | 8 строк, 1363 симв. | 8 строк, 1363 симв. | идентичны | | `cardPrompt` (DEFAULT_AI_CARD_PROMPT L116123) | 8 строк, 1363 симв. | 8 строк, 1363 симв. | идентичны |
| `aiFilterPrompt` (DEFAULT_AI_FILTER_PROMPT L125141) | 17 строк, 686 симв. | 17 строк, 686 симв. | идентичны | | `aiFilterPrompt` (DEFAULT_AI_FILTER_PROMPT L125141) | 17 строк, 686 симв. | 17 строк, 686 симв. | идентичны |
**Расхождений с data.js не найдено — правки DefaultPrompts.cs не потребовались.** В `constants.py` L63–141 те же тексты, но с расхождениями формулировок и разбивки на строки (напр., пункт «4. title»: constants.py L7980 — «без эмодзи, хэштегов и знаков препинания», а в data.js L103 — «без эмодзи, хэштегов, markdown-разметки…»); источник дефолтов — data.js (фронт), поэтому дефолты не менялись. **Расхождений с data.js не найдено — правки DefaultPrompts.cs не потребовались.** В `constants.py` L63–141 те же тексты, но с расхождениями формулировок и разбивки на строки (напр., пункт «4. title»: constants.py L7980 — «без эмодзи, хэштегов и знаков препинания», а в data.js L103 — «без эмодзи, хэштегов, markdown-разметки…»); источник дефолтов — data.js (фронт), поэтому дефолты не менялись.
## Изменения ## Изменения
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `src/core/Deal.Modules.Settings/Application/PromptFiller.cs` | create | Подстановка `{domain}`/`{keywords}` в текст промпта (чистая функция, аналог `ai.fill_prompt` L6377): пустой domain → фраза-фолбэк `FallbackDomain`; keywords — trim/отбрасывание пустых, склейка «, », срез ≤60 (`MaxKeywords`); пустые keywords → `NoKeywordsHint`; пустой prompt → `""`. Читает значения из аргументов (не из хранилища) — чтение `domainDescription`/`domainKeywords` остаётся за вызывающей стороной (этап 6). | | `src/core/Deal.Modules.Settings/Application/PromptFiller.cs` | create | Подстановка `{domain}`/`{keywords}` в текст промпта (чистая функция, аналог `ai.fill_prompt` L6377): пустой domain → фраза-фолбэк `FallbackDomain`; keywords — trim/отбрасывание пустых, склейка «, », срез ≤60 (`MaxKeywords`); пустые keywords → `NoKeywordsHint`; пустой prompt → `""`. Читает значения из аргументов (не из хранилища) — чтение `domainDescription`/`domainKeywords` остаётся за вызывающей стороной (этап 6). |
| `tests/Deal.Tests.Unit/PromptDefaultsTests.cs` | create | 15 тестов: маркеры из data.js («Ты — классификатор входящих сообщений», «О заявке», «страж входящих», `{domain}`/`{keywords}`); построчная сверка трёх дефолтов с data.js (граница 1:1); семантика fill-подстановки (фолбэк domain, склейка keywords, ≤60, trim, замена всех вхождений, пустой prompt). | | `tests/Deal.Tests.Unit/PromptDefaultsTests.cs` | create | 15 тестов: маркеры из data.js («Ты — классификатор входящих сообщений», «О заявке», «страж входящих», `{domain}`/`{keywords}`); построчная сверка трёх дефолтов с data.js (граница 1:1); семантика fill-подстановки (фолбэк domain, склейка keywords, ≤60, trim, замена всех вхождений, пустой prompt). |
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-7-curl-acceptance.sh/.log; тела — UTF-8-файлы *.json) ## Приёмка (curl :5080, admin/admin; скрипт + лог: task-7-curl-acceptance.sh/.log; тела — UTF-8-файлы *.json)
1. GET без куки → 401 `{"detail":"Требуется авторизация"}`. 1. GET без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → дефолты: `aiPrompt` из data.js (маркеры и `{domain}`/`{keywords}`), `myPrompts:[]`. 2. login → дефолты: `aiPrompt` из data.js (маркеры и `{domain}`/`{keywords}`), `myPrompts:[]`.
3. PATCH `aiPrompt` с плейсхолдерами → GET возвращает **тот же текст** (плейсхолдеры как есть). 3. PATCH `aiPrompt` с плейсхолдерами → GET возвращает **тот же текст** (плейсхолдеры как есть).
4. PATCH `myPrompts` 3 записи (`pp_aaaa1111`, `pp_bbbb2222`, `pp_cccc3333`; id/name/description/prompt, в т.ч. пустой description) → ответ и GET содержат все 3 (camelCase); psql: строки `aiPrompt`/`myPrompts` в `settings`, `value_json` с записями. 4. PATCH `myPrompts` 3 записи (`pp_aaaa1111`, `pp_bbbb2222`, `pp_cccc3333`; id/name/description/prompt, в т.ч. пустой description) → ответ и GET содержат все 3 (camelCase); psql: строки `aiPrompt`/`myPrompts` в `settings`, `value_json` с записями.
5. logout → GET снова 401. Итог: **PASS=19 FAIL=0**; dev-БД очищена, сервер остановлен. 5. logout → GET снова 401. Итог: **PASS=19 FAIL=0**; dev-БД очищена, сервер остановлен.
## Concerns / замечания ## Concerns / замечания
1. **Git-Bash + curl.exe (Windows)**: кириллица в аргументах `curl -d '…'` перекодируется в cp1251 → сервер отвечал HTTP 500 (invalid UTF-8). В приёмочном скрипте JSON-тела передаются из UTF-8-файлов (`--data-binary @file`) — для последующих задач держать в уме. 1. **Git-Bash + curl.exe (Windows)**: кириллица в аргументах `curl -d '…'` перекодируется в cp1251 → сервер отвечал HTTP 500 (invalid UTF-8). В приёмочном скрипте JSON-тела передаются из UTF-8-файлов (`--data-binary @file`) — для последующих задач держать в уме.
2. Тест `PromptDefaultsTests` читает `data.js` (путь от папки с `Deal.sln`): перенос фронта сломает тест с понятным сообщением — это намеренная интеграционная сверка 1:1 границы (Task 2 откладывал полное сравнение строк именно на Task 7). 2. Тест `PromptDefaultsTests` читает `data.js` (путь от папки с `Deal.sln`): перенос фронта сломает тест с понятным сообщением — это намеренная интеграционная сверка 1:1 границы (Task 2 откладывал полное сравнение строк именно на Task 7).
3. `PromptFiller` — осознанно чистая функция с параметрами `(prompt, domain, keywords)`, в отличие от `ai.py fill_prompt`, который сам читает store: модуль Settings не получает зависимость на хранилище внутри утилиты (YAGNI до этапа 6). 3. `PromptFiller` — осознанно чистая функция с параметрами `(prompt, domain, keywords)`, в отличие от `ai.py fill_prompt`, который сам читает store: модуль Settings не получает зависимость на хранилище внутри утилиты (YAGNI до этапа 6).
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 113, пройдено 113, пропущено 0 dotnet test Deal.sln → всего 113, пройдено 113, пропущено 0
node task-7-sverka-prompts.mjs → ИТОГ: 3/3 совпали (построчно) node task-7-sverka-prompts.mjs → ИТОГ: 3/3 совпали (построчно)
sh task-7-curl-acceptance.sh → PASS=19 FAIL=0 (лог task-7-curl-acceptance.log) sh task-7-curl-acceptance.sh → PASS=19 FAIL=0 (лог task-7-curl-acceptance.log)
``` ```
@@ -1,71 +1,71 @@
// Сверка текстов DefaultPrompts.cs (C#) с data.js (фронт — высший авторитет, план L47). // Сверка текстов DefaultPrompts.cs (C#) с data.js (фронт — высший авторитет, план L47).
// Извлекает три DEFAULT_* из data.js (между обратными кавычками шаблона) и тела raw-string // Извлекает три DEFAULT_* из data.js (между обратными кавычками шаблона) и тела raw-string
// литералов DefaultPrompts.cs, нормализует \r\n→\n и завершающие \n (семантика Normalize), // литералов DefaultPrompts.cs, нормализует \r\n→\n и завершающие \n (семантика Normalize),
// сравнивает построчно и печатает расхождения. Запуск: node task-7-sverka-prompts.mjs // сравнивает построчно и печатает расхождения. Запуск: node task-7-sverka-prompts.mjs
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const DATA_JS = 'C:/telbase/src/frontend/src/data.js' const DATA_JS = 'C:/telbase/src/frontend/src/data.js'
const CS_FILE = 'C:/telbase/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs' const CS_FILE = 'C:/telbase/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs'
const PAIRS = [ const PAIRS = [
['DEFAULT_AI_PROMPT', 'DefaultAiPrompt'], ['DEFAULT_AI_PROMPT', 'DefaultAiPrompt'],
['DEFAULT_AI_CARD_PROMPT', 'DefaultCardPrompt'], ['DEFAULT_AI_CARD_PROMPT', 'DefaultCardPrompt'],
['DEFAULT_AI_FILTER_PROMPT', 'DefaultAiFilterPrompt'], ['DEFAULT_AI_FILTER_PROMPT', 'DefaultAiFilterPrompt'],
] ]
function extractDataJs(source, exportName) { function extractDataJs(source, exportName) {
const re = new RegExp('export const ' + exportName + ' = `([\\s\\S]*?)`', 'm') const re = new RegExp('export const ' + exportName + ' = `([\\s\\S]*?)`', 'm')
const m = source.match(re) const m = source.match(re)
if (!m) throw new Error('data.js: блок ' + exportName + ' не найден') if (!m) throw new Error('data.js: блок ' + exportName + ' не найден')
return m[1].replace(/\r\n/g, '\n') return m[1].replace(/\r\n/g, '\n')
} }
function extractCs(source, propName) { function extractCs(source, propName) {
const normalized = source.replace(/\r\n/g, '\n') const normalized = source.replace(/\r\n/g, '\n')
const re = new RegExp( const re = new RegExp(
'public static readonly string ' + propName + ' = Normalize\\(\\s*"""\\n([\\s\\S]*?)\\n(\\s*)"""\\);', 'public static readonly string ' + propName + ' = Normalize\\(\\s*"""\\n([\\s\\S]*?)\\n(\\s*)"""\\);',
) )
const m = normalized.match(re) const m = normalized.match(re)
if (!m) throw new Error('DefaultPrompts.cs: блок ' + propName + ' не найден') if (!m) throw new Error('DefaultPrompts.cs: блок ' + propName + ' не найден')
const indent = m[2] const indent = m[2]
const lines = m[1].split('\n').map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line)) const lines = m[1].split('\n').map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
let text = lines.join('\n').replace(/\r/g, '') let text = lines.join('\n').replace(/\r/g, '')
text = text.replace(/\n+$/, '') // TrimEnd('\n') как в Normalize text = text.replace(/\n+$/, '') // TrimEnd('\n') как в Normalize
return text return text
} }
const js = readFileSync(DATA_JS, 'utf8') const js = readFileSync(DATA_JS, 'utf8')
const cs = readFileSync(CS_FILE, 'utf8') const cs = readFileSync(CS_FILE, 'utf8')
let totalFail = 0 let totalFail = 0
for (const [jsName, csProp] of PAIRS) { for (const [jsName, csProp] of PAIRS) {
const expected = extractDataJs(js, jsName) const expected = extractDataJs(js, jsName)
const actual = extractCs(cs, csProp) const actual = extractCs(cs, csProp)
const expLines = expected.split('\n') const expLines = expected.split('\n')
const actLines = actual.split('\n') const actLines = actual.split('\n')
console.log(`=== ${jsName} <-> ${csProp} ===`) console.log(`=== ${jsName} <-> ${csProp} ===`)
console.log(` data.js: ${expLines.length} строк, ${expected.length} симв.`) console.log(` data.js: ${expLines.length} строк, ${expected.length} симв.`)
console.log(` C#: ${actLines.length} строк, ${actual.length} симв.`) console.log(` C#: ${actLines.length} строк, ${actual.length} симв.`)
console.log(` хвостовой \\n: data.js=${expected.endsWith('\n')} C#=${actual.endsWith('\n')}`) console.log(` хвостовой \\n: data.js=${expected.endsWith('\n')} C#=${actual.endsWith('\n')}`)
if (expected === actual) { if (expected === actual) {
console.log(' [OK] тексты идентичны') console.log(' [OK] тексты идентичны')
continue continue
} }
totalFail++ totalFail++
console.log(' [DIFF] есть расхождения:') console.log(' [DIFF] есть расхождения:')
const n = Math.max(expLines.length, actLines.length) const n = Math.max(expLines.length, actLines.length)
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const e = expLines[i] ?? '<нет строки>' const e = expLines[i] ?? '<нет строки>'
const a = actLines[i] ?? '<нет строки>' const a = actLines[i] ?? '<нет строки>'
if (e !== a) { if (e !== a) {
console.log(` строка ${i + 1}:`) console.log(` строка ${i + 1}:`)
console.log(` data.js: ${JSON.stringify(e)}`) console.log(` data.js: ${JSON.stringify(e)}`)
console.log(` C#: ${JSON.stringify(a)}`) console.log(` C#: ${JSON.stringify(a)}`)
} }
} }
} }
console.log(totalFail === 0 ? '\nИТОГ: 3/3 совпали' : `\nИТОГ: расхождений в ${totalFail} блоках`) console.log(totalFail === 0 ? '\nИТОГ: 3/3 совпали' : `\nИТОГ: расхождений в ${totalFail} блоках`)
process.exit(totalFail === 0 ? 0 : 1) process.exit(totalFail === 0 ? 0 : 1)
@@ -1,221 +1,221 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 8 curl-приёмка /api/rates* на :5080 (план Task 8 L329331; Ruling 6). # Task 8 curl-приёмка /api/rates* на :5080 (план Task 8 L329331; Ruling 6).
# Сценарий: 401 без куки → login admin/admin → GET /api/rates (нет кэша: source mock, updatedAt null) → # Сценарий: 401 без куки → login admin/admin → GET /api/rates (нет кэша: source mock, updatedAt null) →
# GET /api/settings не содержит внутренний ratesCache → PATCH {"rateSource":"mock"} → # GET /api/settings не содержит внутренний ratesCache → PATCH {"rateSource":"mock"} →
# POST /api/rates/refresh → {ok:true, rates.source mock, updatedAt ms} → GET /api/rates — тот же кэш → # POST /api/rates/refresh → {ok:true, rates.source mock, updatedAt ms} → GET /api/rates — тот же кэш →
# psql: строка ratesCache {rates, source, updatedAtMs} → реальный cbr (PATCH + refresh → ok:true, source cbr) # psql: строка ratesCache {rates, source, updatedAtMs} → реальный cbr (PATCH + refresh → ok:true, source cbr)
# → logout → GET 401. Вывод всех шагов в stdout. # → logout → GET 401. Вывод всех шагов в stdout.
set -u set -u
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task8-jar.txt" JAR="/tmp/task8-jar.txt"
OUT="/tmp/task8-out.txt" OUT="/tmp/task8-out.txt"
LOG="/tmp/task8-api.log" LOG="/tmp/task8-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
check_not() { check_not() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе # $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1 desc=$1
pattern=$2 pattern=$2
if grep -qF -- "$pattern" "$OUT"; then if grep -qF -- "$pattern" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не ожидалось: $pattern" echo " [FAIL] $desc — не ожидалось: $pattern"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pattern)" echo " [PASS] $desc (отсутствует: $pattern)"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. GET /api/rates без куки — ожидаем 401 ==" echo "== 1. GET /api/rates без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. GET /api/rates — кэша нет: мок-курсы, source mock, updatedAt null ==" echo "== 3. GET /api/rates — кэша нет: мок-курсы, source mock, updatedAt null =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200, дефолт-мок" '[HTTP:200]' '"base":"RUB"' '"source":"mock"' '"updatedAt":null' check "GET 200, дефолт-мок" '[HTTP:200]' '"base":"RUB"' '"source":"mock"' '"updatedAt":null'
check "курсы на месте" '"RUB":1' '"USD":92.5' '"EUR":99.9' '"USDT":92.5' check "курсы на месте" '"RUB":1' '"USD":92.5' '"EUR":99.9' '"USDT":92.5'
# GET при пустом кэше и rateSource=cbr (дефолт) мог запустить фоновый refresh ЦБ — даём ему завершиться, # GET при пустом кэше и rateSource=cbr (дефолт) мог запустить фоновый refresh ЦБ — даём ему завершиться,
# чтобы он не перетёр мок-кэш следующих шагов (интернет в окружении есть). # чтобы он не перетёр мок-кэш следующих шагов (интернет в окружении есть).
echo "== 3b. Пауза 4 с: фоновый refresh (если запустился) успевает завершиться ==" echo "== 3b. Пауза 4 с: фоновый refresh (если запустился) успевает завершиться =="
sleep 4 sleep 4
echo echo
echo "== 4. GET /api/settings — внутренний ключ ratesCache НЕ публикуется ==" echo "== 4. GET /api/settings — внутренний ключ ratesCache НЕ публикуется =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET settings 200" '[HTTP:200]' check "GET settings 200" '[HTTP:200]'
check_not "нет ratesCache в public-снимке" 'ratesCache' check_not "нет ratesCache в public-снимке" 'ratesCache'
check "rateSource в снимке есть (дефолт/текущий)" '"rateSource":"' check "rateSource в снимке есть (дефолт/текущий)" '"rateSource":"'
echo echo
echo "== 5. PATCH {\"rateSource\":\"mock\"} (фоновый refresh по Ruling 6) ==" echo "== 5. PATCH {\"rateSource\":\"mock\"} (фоновый refresh по Ruling 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT" -H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, rateSource=mock" '[HTTP:200]' '"rateSource":"mock"' check "PATCH 200, rateSource=mock" '[HTTP:200]' '"rateSource":"mock"'
echo echo
echo "== 6. POST /api/rates/refresh — ok:true, мок-кэш с updatedAt ==" echo "== 6. POST /api/rates/refresh — ok:true, мок-кэш с updatedAt =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "refresh 200 ok:true" '[HTTP:200]' '"ok":true' check "refresh 200 ok:true" '[HTTP:200]' '"ok":true'
check "rates.source mock, base RUB, курсы" '"source":"mock"' '"base":"RUB"' '"RUB":1' '"USD":92.5' check "rates.source mock, base RUB, курсы" '"source":"mock"' '"base":"RUB"' '"RUB":1' '"USD":92.5'
check_not "updatedAt не null после refresh" '"updatedAt":null' check_not "updatedAt не null после refresh" '"updatedAt":null'
echo echo
echo "== 7. GET /api/rates — тот же кэш (source mock, updatedAt на месте) ==" echo "== 7. GET /api/rates — тот же кэш (source mock, updatedAt на месте) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200, тот же кэш" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5' check "GET 200, тот же кэш" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
check_not "updatedAt не null (кэш сохранён)" '"updatedAt":null' check_not "updatedAt не null (кэш сохранён)" '"updatedAt":null'
echo echo
echo "== 8. psql: строка ratesCache в settings (внутренний KV-ключ) ==" echo "== 8. psql: строка ratesCache в settings (внутренний KV-ключ) =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";" $PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
CACHE_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'ratesCache';") CACHE_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'ratesCache';")
echo "ratesCache value_json: $CACHE_JSON" echo "ratesCache value_json: $CACHE_JSON"
if echo "$CACHE_JSON" | grep -q '"updatedAtMs"' && echo "$CACHE_JSON" | grep -q '"source":"mock"'; then if echo "$CACHE_JSON" | grep -q '"updatedAtMs"' && echo "$CACHE_JSON" | grep -q '"source":"mock"'; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ratesCache сохранён как {rates, source, updatedAtMs}" echo " [PASS] ratesCache сохранён как {rates, source, updatedAtMs}"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] ratesCache не в форме Ruling 6: $CACHE_JSON" echo " [FAIL] ratesCache не в форме Ruling 6: $CACHE_JSON"
fi fi
echo echo
echo "== 9. Реальный источник cbr: PATCH rateSource=cbr + POST refresh ==" echo "== 9. Реальный источник cbr: PATCH rateSource=cbr + POST refresh =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"cbr"}' > "$OUT" -H "Content-Type: application/json" -d '{"rateSource":"cbr"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200, rateSource=cbr" '[HTTP:200]' '"rateSource":"cbr"' check "PATCH 200, rateSource=cbr" '[HTTP:200]' '"rateSource":"cbr"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "refresh cbr 200 ok:true (ЦБ доступен)" '[HTTP:200]' '"ok":true' check "refresh cbr 200 ok:true (ЦБ доступен)" '[HTTP:200]' '"ok":true'
check "rates.source cbr, base RUB" '"source":"cbr"' '"base":"RUB"' check "rates.source cbr, base RUB" '"source":"cbr"' '"base":"RUB"'
check_not "updatedAt не null после cbr-refresh" '"updatedAt":null' check_not "updatedAt не null после cbr-refresh" '"updatedAt":null'
echo echo
echo "== 9b. GET /api/rates — кэш cbr на месте ==" echo "== 9b. GET /api/rates — кэш cbr на месте =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "GET 200, source cbr" '[HTTP:200]' '"source":"cbr"' '"base":"RUB"' check "GET 200, source cbr" '[HTTP:200]' '"source":"cbr"' '"base":"RUB"'
echo echo
echo "== 10. POST /api/auth/logout, затем GET /api/rates — 401 ==" echo "== 10. POST /api/auth/logout, затем GET /api/rates — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 11. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 11. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1,62 +1,62 @@
# Task 8 — «Курсы валют: сервис, кэш, эндпоинты /api/rates*» — отчёт # Task 8 — «Курсы валют: сервис, кэш, эндпоинты /api/rates*» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 148/148 PASS (было 113, добавлено 35: 26 — `RatesServiceTests`, 8 — `CbrRateSourceTests`, 1 — `FakeRatesSource` хелпер в общем счёте); curl-приёмка :5080 — PASS=22 FAIL=0 (включая реальный запрос к ЦБ РФ). Статус: **complete**. Build 0 warnings / 0 errors; тесты 148/148 PASS (было 113, добавлено 35: 26 — `RatesServiceTests`, 8 — `CbrRateSourceTests`, 1 — `FakeRatesSource` хелпер в общем счёте); curl-приёмка :5080 — PASS=22 FAIL=0 (включая реальный запрос к ЦБ РФ).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 8 L308331, Ruling 6 L8389, Ruling 1; референс `rates.py` целиком, `constants.py` L4152). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 8 L308331, Ruling 6 L8389, Ruling 1; референс `rates.py` целиком, `constants.py` L4152).
## Реализация ## Реализация
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `Deal.Modules.Settings/Application/IRatesSource.cs` | create | Порт источника: `Task<Dictionary<string,double>?> FetchAsync(ct)` — курсы к RUB, null при сбое (план L311). | | `Deal.Modules.Settings/Application/IRatesSource.cs` | create | Порт источника: `Task<Dictionary<string,double>?> FetchAsync(ct)` — курсы к RUB, null при сбое (план L311). |
| `Deal.Modules.Settings/Application/Models/RatesDto.cs` | create | `RatesDto(Base, Rates, Source, UpdatedAtMs?)` — тело GET /rates и `rates.rates` refresh (план L312). Wire 1:1 с прототипом: поле сериализуется как `updatedAt` (`[JsonPropertyName]`) — фронт читает `r.updatedAt` (store.js L408, `applyRates`). | | `Deal.Modules.Settings/Application/Models/RatesDto.cs` | create | `RatesDto(Base, Rates, Source, UpdatedAtMs?)` — тело GET /rates и `rates.rates` refresh (план L312). Wire 1:1 с прототипом: поле сериализуется как `updatedAt` (`[JsonPropertyName]`) — фронт читает `r.updatedAt` (store.js L408, `applyRates`). |
| `Deal.Modules.Settings/Application/Models/RatesCacheValue.cs` | create | Форма KV-значения `ratesCache` = `{rates, source, updatedAtMs}` 1:1 Ruling 6 L83 (не публичный ключ, Ruling 1). | | `Deal.Modules.Settings/Application/Models/RatesCacheValue.cs` | create | Форма KV-значения `ratesCache` = `{rates, source, updatedAtMs}` 1:1 Ruling 6 L83 (не публичный ключ, Ruling 1). |
| `Deal.Modules.Settings/Application/RatesService.cs` | create | `GetAsync` (кэш; нет кэша → дефолт MockRates/source `"mock"`/updatedAt null); `RefreshAsync` (source из настройки: mock → сохранить MockRates без порта; cbr/иное → `IRatesSource`, неуспех → false, кэш не тронут); `ShouldFetchAsync` (нет кэша / смена источника / ≥6 ч); статический чистый `ConvertAmount(amount, from, to, rates)` — USDT=USD, отсутствующая валюта → null, round 2 (L86103). Повреждённые строки хранилища → мягкий дефолт (как SettingsService). | | `Deal.Modules.Settings/Application/RatesService.cs` | create | `GetAsync` (кэш; нет кэша → дефолт MockRates/source `"mock"`/updatedAt null); `RefreshAsync` (source из настройки: mock → сохранить MockRates без порта; cbr/иное → `IRatesSource`, неуспех → false, кэш не тронут); `ShouldFetchAsync` (нет кэша / смена источника / ≥6 ч); статический чистый `ConvertAmount(amount, from, to, rates)` — USDT=USD, отсутствующая валюта → null, round 2 (L86103). Повреждённые строки хранилища → мягкий дефолт (как SettingsService). |
| `Deal.Infrastructure/Integrations/CbrRateSource.cs` | create | HTTP GET `https://www.cbr-xml-daily.ru/daily_json.js` (фиксированный URL — SSRF-allowlist, тенант не управляет адресом), `Valute[code].Value/Nominal` (Nominal>1: 100 KZT), `RUB:1`, round 6; таймаут 15 с; любой сбой → null + warning (лог). | | `Deal.Infrastructure/Integrations/CbrRateSource.cs` | create | HTTP GET `https://www.cbr-xml-daily.ru/daily_json.js` (фиксированный URL — SSRF-allowlist, тенант не управляет адресом), `Valute[code].Value/Nominal` (Nominal>1: 100 KZT), `RUB:1`, round 6; таймаут 15 с; любой сбой → null + warning (лог). |
| `Deal.Api/RatesRefreshScheduler.cs` | create | Фоновый refresh ВНЕ запроса (отдельный scope через `IServiceScopeFactory` + in-flight guard): PATCH rateSource и ленивый GET не спамят ЦБ и не наследуют disposal запросного scope. Тенант пробрасывается через ExecutionContext (AsyncLocal ITenantContext). | | `Deal.Api/RatesRefreshScheduler.cs` | create | Фоновый refresh ВНЕ запроса (отдельный scope через `IServiceScopeFactory` + in-flight guard): PATCH rateSource и ленивый GET не спамят ЦБ и не наследуют disposal запросного scope. Тенант пробрасывается через ExecutionContext (AsyncLocal ITenantContext). |
| `Deal.Api/Endpoints/RatesEndpoints.cs` | create | `MapRatesEndpoints`: GET `/api/rates` → RatesDto (при ShouldFetch — фоновый запуск RefreshAsync, ответ — текущий кэш); POST `/api/rates/refresh``{ok, rates}` (ok=false при сбое cbr; mock — true). 401-гейт {detail}, резолв через RequestServices после гейта (паттерн SettingsEndpoints/AiCheck). | | `Deal.Api/Endpoints/RatesEndpoints.cs` | create | `MapRatesEndpoints`: GET `/api/rates` → RatesDto (при ShouldFetch — фоновый запуск RefreshAsync, ответ — текущий кэш); POST `/api/rates/refresh``{ok, rates}` (ok=false при сбое cbr; mock — true). 401-гейт {detail}, резолв через RequestServices после гейта (паттерн SettingsEndpoints/AiCheck). |
| `Deal.Api/Endpoints/SettingsEndpoints.cs` | modify | PATCH с полем `rateSource``RatesRefreshScheduler.Schedule()` (1:1 L188189: `if body.get("rateSource")`); ссылка на Task 8 в доке класса. | | `Deal.Api/Endpoints/SettingsEndpoints.cs` | modify | PATCH с полем `rateSource``RatesRefreshScheduler.Schedule()` (1:1 L188189: `if body.get("rateSource")`); ссылка на Task 8 в доке класса. |
| `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddScoped<RatesService>()`. | | `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddScoped<RatesService>()`. |
| `Deal.Api/Program.cs` | modify | `app.MapRatesEndpoints()`; `AddHttpClient<IRatesSource, CbrRateSource>` (таймаут 15 с; typed client — как IAiConnectionChecker Task 6); `AddSingleton<RatesRefreshScheduler>()`. | | `Deal.Api/Program.cs` | modify | `app.MapRatesEndpoints()`; `AddHttpClient<IRatesSource, CbrRateSource>` (таймаут 15 с; typed client — как IAiConnectionChecker Task 6); `AddSingleton<RatesRefreshScheduler>()`. |
| `tests/…/FakeRatesSource.cs`, `RatesServiceTests.cs`, `CbrRateSourceTests.cs` | create | 35 тестов (детали ниже). | | `tests/…/FakeRatesSource.cs`, `RatesServiceTests.cs`, `CbrRateSourceTests.cs` | create | 35 тестов (детали ниже). |
## Границы и решения ## Границы и решения
- **RateSource по умолчанию — `"cbr"`** (SettingsDefaults L128/constants.py L226, НЕ "mock"). Дефолт ОТВЕТА при пустом кэше — `source:"mock"` + мок-курсы + updatedAt null (как `get_rates()` rates.py L23–31 без строки; план Task 8 L313–314). Это подтверждено curl: первый GET на чистой БД отдал мок. - **RateSource по умолчанию — `"cbr"`** (SettingsDefaults L128/constants.py L226, НЕ "mock"). Дефолт ОТВЕТА при пустом кэше — `source:"mock"` + мок-курсы + updatedAt null (как `get_rates()` rates.py L23–31 без строки; план Task 8 L313–314). Это подтверждено curl: первый GET на чистой БД отдал мок.
- **ShouldFetch «смена источника» — симметрично** (кэш mock/настройка cbr И кэш cbr/настройка mock; rates.py проверяет только mock→cbr): план L315 «смена источника» + Ruling 6 «лениво на GET при … смене источника»; значение настройки нормализуется к mock|cbr (не-mock → cbr), иначе произвольная строка в rateSource вызывала бы fetch на каждый GET. - **ShouldFetch «смена источника» — симметрично** (кэш mock/настройка cbr И кэш cbr/настройка mock; rates.py проверяет только mock→cbr): план L315 «смена источника» + Ruling 6 «лениво на GET при … смене источника»; значение настройки нормализуется к mock|cbr (не-mock → cbr), иначе произвольная строка в rateSource вызывала бы fetch на каждый GET.
- **PATCH-хук** (L188) живёт в HTTP-слое SettingsEndpoints, как и предписывает докласс SettingsService («побочные эффекты L186–192 выполняются HTTP-слоем — Task 8»). Files-список Task 8 его не называет, но Ruling 6 требует — расхождение плана с самим собой зафиксировано здесь; реализовано по Ruling. - **PATCH-хук** (L188) живёт в HTTP-слое SettingsEndpoints, как и предписывает докласс SettingsService («побочные эффекты L186–192 выполняются HTTP-слоем — Task 8»). Files-список Task 8 его не называет, но Ruling 6 требует — расхождение плана с самим собой зафиксировано здесь; реализовано по Ruling.
- **Фоновые обновления** — fire-and-forget НЕ на запросном scope: `RatesRefreshScheduler` (Api, singleton) создаёт собственный scope (scoped ISettingsStore/TenantDbContext живут, пока идёт HTTP к ЦБ ≤15 с) и держит in-flight guard (1 одновременный refresh на процесс — не спамим ЦБ при частых GET; межтенантный дебаунс осознан: refresh редкий). IHostedService не понадобился — план Task 8 его не требует (Files-список без hosted-сервисов). - **Фоновые обновления** — fire-and-forget НЕ на запросном scope: `RatesRefreshScheduler` (Api, singleton) создаёт собственный scope (scoped ISettingsStore/TenantDbContext живут, пока идёт HTTP к ЦБ ≤15 с) и держит in-flight guard (1 одновременный refresh на процесс — не спамим ЦБ при частых GET; межтенантный дебаунс осознан: refresh редкий). IHostedService не понадобился — план Task 8 его не требует (Files-список без hosted-сервисов).
- **SSRF**: cbr-URL — фиксированная константа адаптера (allowlist); перенаправления — дефолтные (как httpx в python). Тенант не управляет адресом. - **SSRF**: cbr-URL — фиксированная константа адаптера (allowlist); перенаправления — дефолтные (как httpx в python). Тенант не управляет адресом.
- **Фронт**: GET /api/rates (boot store.js L571581), POST /api/rates/refresh (кнопка «Обновить курсы», refreshRates L18431848), PATCH rateSource (schedulePersist) — всё с вкладки «Валюта и курсы» SettingsView; Ruling 8 «только для Settings-экрана» подтверждена, эндпоинты фронтом используются (пункт 4 задания). - **Фронт**: GET /api/rates (boot store.js L571581), POST /api/rates/refresh (кнопка «Обновить курсы», refreshRates L18431848), PATCH rateSource (schedulePersist) — всё с вкладки «Валюта и курсы» SettingsView; Ruling 8 «только для Settings-экрана» подтверждена, эндпоинты фронтом используются (пункт 4 задания).
- **`ConvertAmount`** — чистая функция с параметром `rates` (Ruling 6: в этапе 2 только чистый ConvertAmount; пересчёт карточек — этап 3). - **`ConvertAmount`** — чистая функция с параметром `rates` (Ruling 6: в этапе 2 только чистый ConvertAmount; пересчёт карточек — этап 3).
## Тесты (35 новых; всего 148 PASS) ## Тесты (35 новых; всего 148 PASS)
- `RatesServiceTests` (26): GetAsync — нет кэша (мок/source mock/updatedAt null), кэш, повреждённый кэш → дефолт; Refresh — mock без вызова порта, cbr успех, cbr сбой (false, кэш не тронут), дефолт source cbr, неизвестная настройка → cbr; ShouldFetch — нет кэша, смена источника (обе стороны), свежий кэш (false), ≥6 ч (граница включительно), <6 ч (false), «garbage»-настройка не вызывает fetch на каждый GET, повреждённый кэш; ConvertAmount — USD→RUB, USDT=USD (обе стороны), null-сумма, отсутствующая валюта (from/to), USDT без USD в курсах → собственный курс, cross-currency round 2; wire-формат RatesDto (ключи `base/rates/source/updatedAt`, null updatedAt). - `RatesServiceTests` (26): GetAsync — нет кэша (мок/source mock/updatedAt null), кэш, повреждённый кэш → дефолт; Refresh — mock без вызова порта, cbr успех, cbr сбой (false, кэш не тронут), дефолт source cbr, неизвестная настройка → cbr; ShouldFetch — нет кэша, смена источника (обе стороны), свежий кэш (false), ≥6 ч (граница включительно), <6 ч (false), «garbage»-настройка не вызывает fetch на каждый GET, повреждённый кэш; ConvertAmount — USD→RUB, USDT=USD (обе стороны), null-сумма, отсутствующая валюта (from/to), USDT без USD в курсах → собственный курс, cross-currency round 2; wire-формат RatesDto (ключи `base/rates/source/updatedAt`, null updatedAt).
- `CbrRateSourceTests` (8): парсинг образца daily_json.js (USD/EUR номинал 1, KZT номинал 100 → 0.19; RUB:1 добавлен), round 6, фиксированный URL; ветки сбоя → null: HTTP 500, сетевой сбой, не-JSON, нет объекта Valute, повреждённая запись (Value "abc"); Nominal=0 → 1. - `CbrRateSourceTests` (8): парсинг образца daily_json.js (USD/EUR номинал 1, KZT номинал 100 → 0.19; RUB:1 добавлен), round 6, фиксированный URL; ветки сбоя → null: HTTP 500, сетевой сбой, не-JSON, нет объекта Valute, повреждённая запись (Value "abc"); Nominal=0 → 1.
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-8-curl-acceptance.sh/.log) ## Приёмка (curl :5080, admin/admin; скрипт + лог: task-8-curl-acceptance.sh/.log)
1. GET /rates без куки → 401 `{"detail":"Требуется авторизация"}`. 1. GET /rates без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → GET /rates на чистой БД: `{"base":"RUB","rates":{RUB:1,USD:92.5,…,USDT:92.5},"source":"mock","updatedAt":null}`. 2. login → GET /rates на чистой БД: `{"base":"RUB","rates":{RUB:1,USD:92.5,…,USDT:92.5},"source":"mock","updatedAt":null}`.
3. GET /settings: ключа `ratesCache` в public-снимке НЕТ (Ruling 1); `rateSource` на месте. 3. GET /settings: ключа `ratesCache` в public-снимке НЕТ (Ruling 1); `rateSource` на месте.
4. PATCH `{"rateSource":"mock"}` → 200 `"rateSource":"mock"`. 4. PATCH `{"rateSource":"mock"}` → 200 `"rateSource":"mock"`.
5. POST /rates/refresh → `{"ok":true,"rates":{…,"source":"mock","updatedAt":1788660320268}}`; GET /rates — тот же кэш (updatedAt тот же). 5. POST /rates/refresh → `{"ok":true,"rates":{…,"source":"mock","updatedAt":1788660320268}}`; GET /rates — тот же кэш (updatedAt тот же).
6. psql: строка `ratesCache` в settings: `{"rates":{…},"source":"mock","updatedAtMs":1788660320268}` (форма Ruling 6). 6. psql: строка `ratesCache` в settings: `{"rates":{…},"source":"mock","updatedAtMs":1788660320268}` (форма Ruling 6).
7. Реальный ЦБ (интернет в окружении есть): PATCH `{"rateSource":"cbr"}` + POST /rates/refresh → `ok:true`, `source:"cbr"`, `updatedAt` (курсы ЦБ: USD 86.5857, EUR 100.5693, 55+ валют, KZT 0.189939…); GET /rates — кэш cbr на месте. 7. Реальный ЦБ (интернет в окружении есть): PATCH `{"rateSource":"cbr"}` + POST /rates/refresh → `ok:true`, `source:"cbr"`, `updatedAt` (курсы ЦБ: USD 86.5857, EUR 100.5693, 55+ валют, KZT 0.189939…); GET /rates — кэш cbr на месте.
8. logout → GET /rates = 401. Итог: **PASS=22 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен). 8. logout → GET /rates = 401. Итог: **PASS=22 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания ## Concerns / замечания
1. **Wire-нотация маленьких курсов**: реальный ЦБ-кэш содержит `"IRR":5.4E-05` (STJ-сериализация double в экспоненте с верхним E; python json.dumps пишет `5.4e-05`). Оба — валидный JSON, фронтовый `JSON.parse`/`toLocaleString` корректен — косметическое расхождение с прототипом. 1. **Wire-нотация маленьких курсов**: реальный ЦБ-кэш содержит `"IRR":5.4E-05` (STJ-сериализация double в экспоненте с верхним E; python json.dumps пишет `5.4e-05`). Оба — валидный JSON, фронтовый `JSON.parse`/`toLocaleString` корректен — косметическое расхождение с прототипом.
2. **Запись mock-кэша ставит updatedAt = now**: после первого refresh `updatedAt` не null даже в mock-режиме — это 1:1 с python `save_rates` (acceptance L330 ожидает `updatedAt:<ms>`); фронт показывает «мок-курсы» только когда кэша ещё не было. 2. **Запись mock-кэша ставит updatedAt = now**: после первого refresh `updatedAt` не null даже в mock-режиме — это 1:1 с python `save_rates` (acceptance L330 ожидает `updatedAt:<ms>`); фронт показывает «мок-курсы» только когда кэша ещё не было.
3. **Typed client `AddHttpClient<IRatesSource, CbrRateSource>`** регистрируется transient (как IAiConnectionChecker, Task 6), план писал «scoped»: внутри scope запроса/фоновой работы поведение эквивалентно (клиент живёт в рамках scope, сбой-безопасно); расхождение формулировок зафиксировано. 3. **Typed client `AddHttpClient<IRatesSource, CbrRateSource>`** регистрируется transient (как IAiConnectionChecker, Task 6), план писал «scoped»: внутри scope запроса/фоновой работы поведение эквивалентно (клиент живёт в рамках scope, сбой-безопасно); расхождение формулировок зафиксировано.
4. **Межтенантный дебаунс** фонового refresh (1 на процесс) осознан: обновление редкое (≤1/6 ч на тенант), худший случай — отложенный на секунды refresh второго тенанта. 4. **Межтенантный дебаунс** фонового refresh (1 на процесс) осознан: обновление редкое (≤1/6 ч на тенант), худший случай — отложенный на секунды refresh второго тенанта.
5. 401-ветки эндпоинтов проверены curl (без куки/после logout), юнит-инфраструктуры хостинга Api в проекте нет (как и для прошлых задач) — ветки тела ответа покрыты на уровне сервисов + wire-тест RatesDto. 5. 401-ветки эндпоинтов проверены curl (без куки/после logout), юнит-инфраструктуры хостинга Api в проекте нет (как и для прошлых задач) — ветки тела ответа покрыты на уровне сервисов + wire-тест RatesDto.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 148, пройдено 148, пропущено 0 (было 113, +35) dotnet test Deal.sln → всего 148, пройдено 148, пропущено 0 (было 113, +35)
sh task-8-curl-acceptance.sh → PASS=22 FAIL=0 (лог task-8-curl-acceptance.log) sh task-8-curl-acceptance.sh → PASS=22 FAIL=0 (лог task-8-curl-acceptance.log)
``` ```
@@ -1,239 +1,239 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Task 9 curl-приёмка /api/ml* на :5080 (план Task 9 L361-365; Ruling 5/8; ml_routes.py). # Task 9 curl-приёмка /api/ml* на :5080 (план Task 9 L361-365; Ruling 5/8; ml_routes.py).
# Сценарий: 401 без куки → login admin/admin → GET /api/ml/status (форма §4.10 L363: reachable:true, # Сценарий: 401 без куки → login admin/admin → GET /api/ml/status (форма §4.10 L363: reachable:true,
# service.ready:false, eval обнулён, stats.outbox:0) → PATCH mlEnabled=false → статус enabled:false → # service.ready:false, eval обнулён, stats.outbox:0) → PATCH mlEnabled=false → статус enabled:false →
# PATCH true → predict {"text":"x"} → 400 «Введите текст» → predict с текстом (UTF-8 файл) → # PATCH true → predict {"text":"x"} → 400 «Введите текст» → predict с текстом (UTF-8 файл) →
# take:false/label:null/scores:{} → reset {ok:true} → candidates {items:[]} → apply 404 # take:false/label:null/scores:{} → reset {ok:true} → candidates {items:[]} → apply 404
# «Исходное сообщение не найдено» → psql: нет таблиц ml_outbox/learning_log → logout → 401. # «Исходное сообщение не найдено» → psql: нет таблиц ml_outbox/learning_log → logout → 401.
# Вывод всех шагов в stdout. # Вывод всех шагов в stdout.
set -u set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080" BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api" API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe" APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json" PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
JAR="/tmp/task9-jar.txt" JAR="/tmp/task9-jar.txt"
OUT="/tmp/task9-out.txt" OUT="/tmp/task9-out.txt"
LOG="/tmp/task9-api.log" LOG="/tmp/task9-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal" PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001" SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
check() { check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT) # $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1 desc=$1
shift shift
ok=1 ok=1
for pat in "$@"; do for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then if ! grep -qF -- "$pat" "$OUT"; then
ok=0 ok=0
fi fi
done done
if [ "$ok" = 1 ]; then if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc" echo " [PASS] $desc"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*" echo " [FAIL] $desc — не найдено: $*"
fi fi
} }
check_not() { check_not() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе # $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1 desc=$1
pattern=$2 pattern=$2
if grep -qF -- "$pattern" "$OUT"; then if grep -qF -- "$pattern" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не ожидалось: $pattern" echo " [FAIL] $desc — не ожидалось: $pattern"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pattern)" echo " [PASS] $desc (отсутствует: $pattern)"
fi fi
} }
cleanup() { cleanup() {
echo echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) ==" echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null kill "$APP_PID" 2>/dev/null
sleep 2 sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null taskkill //F //PID "$APP_PID" 2>/dev/null
fi fi
rm -f "$JAR" "$OUT" rm -f "$JAR" "$OUT"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) ==" echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста" echo " [PASS] таблица settings пуста"
else else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT" echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1 exit 1
fi fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) ==" echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1 cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 & ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$! APP_PID=$!
i=0 i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1)) i=$((i + 1))
if [ "$i" -ge 30 ]; then if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)" echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG" tail -n 20 "$LOG"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")" echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo echo
echo "== 1. GET /api/ml/status без куки — ожидаем 401 ==" echo "== 1. GET /api/ml/status без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/ml/status" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"' check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 2. POST /api/auth/login admin/admin ==" echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"' check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo echo
echo "== 3. GET /api/ml/status — полная форма §4.10 (заглушка: reachable:true, ready:false) ==" echo "== 3. GET /api/ml/status — полная форма §4.10 (заглушка: reachable:true, ready:false) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "status 200" '[HTTP:200]' check "status 200" '[HTTP:200]'
check "enabled:true (дефолт mlEnabled)" '"enabled":true' check "enabled:true (дефолт mlEnabled)" '"enabled":true'
check "reachable:true" '"reachable":true' check "reachable:true" '"reachable":true'
check "service.ready:false" '"service":{"ready":false' check "service.ready:false" '"service":{"ready":false'
check "service.classes {} и learned:0" '"classes":{},"learned":0' check "service.classes {} и learned:0" '"classes":{},"learned":0'
check "service.eval обнулён" '"eval":{"count":0,"correct":0,"accuracy":0' check "service.eval обнулён" '"eval":{"count":0,"correct":0,"accuracy":0'
check "stats.ml/ai 0" '"stats":{"ml":0,"ai":0' check "stats.ml/ai 0" '"stats":{"ml":0,"ai":0'
check "stats.learning/outbox 0" '"learning":0' '"outbox":0' check "stats.learning/outbox 0" '"learning":0' '"outbox":0'
check "stats.reachable:true" '"stats":{"ml":0,"ai":0,"learning":0,"ready":false,"classes":{},"learned":0,"reachable":true' check "stats.reachable:true" '"stats":{"ml":0,"ai":0,"learning":0,"ready":false,"classes":{},"learned":0,"reachable":true'
echo echo
echo "== 4. PATCH {\"mlEnabled\":false} → статус enabled:false (счётчики из KV, Ruling 1) ==" echo "== 4. PATCH {\"mlEnabled\":false} → статус enabled:false (счётчики из KV, Ruling 1) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"mlEnabled":false}' > "$OUT" -H "Content-Type: application/json" -d '{"mlEnabled":false}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200 mlEnabled:false" '[HTTP:200]' '"mlEnabled":false' check "PATCH 200 mlEnabled:false" '[HTTP:200]' '"mlEnabled":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
check "status enabled:false" '[HTTP:200]' '"enabled":false' check "status enabled:false" '[HTTP:200]' '"enabled":false'
check_not "нет service в ответе" '"error"' check_not "нет service в ответе" '"error"'
echo echo
echo "== 4b. PATCH {\"mlEnabled\":true} — возврат дефолта ==" echo "== 4b. PATCH {\"mlEnabled\":true} — возврат дефолта =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"mlEnabled":true}' > "$OUT" -H "Content-Type: application/json" -d '{"mlEnabled":true}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "PATCH 200 mlEnabled:true" '[HTTP:200]' '"mlEnabled":true' check "PATCH 200 mlEnabled:true" '[HTTP:200]' '"mlEnabled":true'
echo echo
echo "== 5. POST /api/ml/predict {\"text\":\"x\"} — 400 «Введите текст» ==" echo "== 5. POST /api/ml/predict {\"text\":\"x\"} — 400 «Введите текст» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" -d '{"text":"x"}' > "$OUT" -H "Content-Type: application/json" -d '{"text":"x"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "predict 400" '[HTTP:400]' '"detail":"Введите текст"' check "predict 400" '[HTTP:400]' '"detail":"Введите текст"'
echo echo
echo "== 6. POST /api/ml/predict с текстом (UTF-8 из файла) — «не уверен», все поля ==" echo "== 6. POST /api/ml/predict с текстом (UTF-8 из файла) — «не уверен», все поля =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT" -H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "predict 200" '[HTTP:200]' check "predict 200" '[HTTP:200]'
check "text эхом (кириллица без \\u-экранов)" 'Python backend на fastapi, бот в телеграм, удалённо, сделка' check "text эхом (кириллица без \\u-экранов)" 'Python backend на fastapi, бот в телеграм, удалённо, сделка'
check "take:false, label:null, scores:{}" '"take":false,"label":null,"scores":{}' check "take:false, label:null, scores:{}" '"take":false,"label":null,"scores":{}'
check "hits:0, ready:false" '"hits":0,"ready":false' check "hits:0, ready:false" '"hits":0,"ready":false'
check "margin:null, terms:[], type:null" '"margin":null,"terms":[],"type":null' check "margin:null, terms:[], type:null" '"margin":null,"terms":[],"type":null'
echo echo
echo "== 7. POST /api/ml/reset — {ok:true} (без ключа error) ==" echo "== 7. POST /api/ml/reset — {ok:true} (без ключа error) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "reset 200" '[HTTP:200]' '{"ok":true}' check "reset 200" '[HTTP:200]' '{"ok":true}'
check_not "нет поля error при успехе" '"error"' check_not "нет поля error при успехе" '"error"'
echo echo
echo "== 8. POST /api/ml/candidates — {items: []} (данных telegram нет до этапа 6) ==" echo "== 8. POST /api/ml/candidates — {items: []} (данных telegram нет до этапа 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/candidates" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/candidates" \
-H "Content-Type: application/json" -d '{"dialogId":"chan_test","limit":10}' > "$OUT" -H "Content-Type: application/json" -d '{"dialogId":"chan_test","limit":10}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "candidates 200" '[HTTP:200]' '{"items":[]}' check "candidates 200" '[HTTP:200]' '{"items":[]}'
echo echo
echo "== 9. POST /api/ml/apply — 404 «Исходное сообщение не найдено» ==" echo "== 9. POST /api/ml/apply — 404 «Исходное сообщение не найдено» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/apply" \ curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/apply" \
-H "Content-Type: application/json" -d '{"dialogId":"chan_test","msgId":1,"action":"spam"}' > "$OUT" -H "Content-Type: application/json" -d '{"dialogId":"chan_test","msgId":1,"action":"spam"}' > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "apply 404" '[HTTP:404]' '"detail":"Исходное сообщение не найдено"' check "apply 404" '[HTTP:404]' '"detail":"Исходное сообщение не найдено"'
echo echo
echo "== 10. psql: таблиц ml_outbox/learning_log в схеме тенанта нет (Ruling 5 L81-82) ==" echo "== 10. psql: таблиц ml_outbox/learning_log в схеме тенанта нет (Ruling 5 L81-82) =="
$PSQL_BASE -t -A -c "SELECT tablename FROM pg_tables WHERE schemaname = '$SCHEMA' AND tablename NOT IN ('settings', '__TenantMigrationsHistory');" > "$OUT" $PSQL_BASE -t -A -c "SELECT tablename FROM pg_tables WHERE schemaname = '$SCHEMA' AND tablename NOT IN ('settings', '__TenantMigrationsHistory');" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
if [ -s "$OUT" ]; then if [ -s "$OUT" ]; then
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в схеме тенанта есть лишние таблицы (ml_outbox/learning_log?): $(tr '\n' ' ' < "$OUT")" echo " [FAIL] в схеме тенанта есть лишние таблицы (ml_outbox/learning_log?): $(tr '\n' ' ' < "$OUT")"
else else
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в схеме тенанта только settings (и история миграций)" echo " [PASS] в схеме тенанта только settings (и история миграций)"
fi fi
echo echo
echo "== 11. POST /api/auth/logout, затем /api/ml* — 401 ==" echo "== 11. POST /api/auth/logout, затем /api/ml* — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "logout 200" '[HTTP:200]' '"ok":true' check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "после logout status 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout status 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT" curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT"
cat "$OUT" cat "$OUT"
echo echo
check "после logout reset 401" '[HTTP:401]' '"detail":"Требуется авторизация"' check "после logout reset 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo echo
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) ==" echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" $PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;") ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1)) PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)" echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else else
FAIL_COUNT=$((FAIL_COUNT + 1)) FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)" echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi fi
echo echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT ==" echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки" echo " [FAIL] есть проваленные проверки"
exit 1 exit 1
fi fi
echo " [PASS] все проверки curl-приёмки прошли" echo " [PASS] все проверки curl-приёмки прошли"
@@ -1 +1 @@
{"text":"Python backend на fastapi, бот в телеграм, удалённо, сделка"} {"text":"Python backend на fastapi, бот в телеграм, удалённо, сделка"}
@@ -1,64 +1,64 @@
# Task 9 — «ML-панель: порт IMlClient, детерминированная заглушка, эндпоинты /api/ml» — отчёт # Task 9 — «ML-панель: порт IMlClient, детерминированная заглушка, эндпоинты /api/ml» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 161/161 PASS (было 148, добавлено 13: `LocalMlClientTests`, +1 прямой ProjectReference `Deal.Contracts` в тестовый проект); curl-приёмка :5080 — PASS=31 FAIL=0. Статус: **complete**. Build 0 warnings / 0 errors; тесты 161/161 PASS (было 148, добавлено 13: `LocalMlClientTests`, +1 прямой ProjectReference `Deal.Contracts` в тестовый проект); curl-приёмка :5080 — PASS=31 FAIL=0.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 9 L333367, Ruling 4 L7175, Ruling 5 L7682; референс `ml_routes.py` L6691/L112171, `ml_client.py` L101150, `mlservice/model.py` predict/status; фронт `MLPanel.vue` + `store.js` applyMlStatus L487502). Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 9 L333367, Ruling 4 L7175, Ruling 5 L7682; референс `ml_routes.py` L6691/L112171, `ml_client.py` L101150, `mlservice/model.py` predict/status; фронт `MLPanel.vue` + `store.js` applyMlStatus L487502).
## Реализация ## Реализация
| Файл | Тип | Содержание | | Файл | Тип | Содержание |
|---|---|---| |---|---|---|
| `Deal.Contracts/Integrations/IMlClient.cs` | create | Порт внешнего ML-сервиса (Ruling 4): `StatusAsync/PredictAsync/ResetAsync` (ct-параметры). PushAsync НЕ объявлен — добавится этапом 3 (Ruling 5). | | `Deal.Contracts/Integrations/IMlClient.cs` | create | Порт внешнего ML-сервиса (Ruling 4): `StatusAsync/PredictAsync/ResetAsync` (ct-параметры). PushAsync НЕ объявлен — добавится этапом 3 (Ruling 5). |
| `Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs` | create | `{Ready, Classes, Learned, Eval}` — «сырой» статус ML-сервиса 1:1 `model.py` status L325345. | | `Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs` | create | `{Ready, Classes, Learned, Eval}` — «сырой» статус ML-сервиса 1:1 `model.py` status L325345. |
| `Deal.Contracts/Integrations/Models/MlEvalDto.cs` | create | `{Count, Correct, Accuracy}` — самооценка модели. | | `Deal.Contracts/Integrations/Models/MlEvalDto.cs` | create | `{Count, Correct, Accuracy}` — самооценка модели. |
| `Deal.Contracts/Integrations/Models/MlStatsDto.cs` | create | `{Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox}` — локальная статистика 1:1 `ml_client.snapshot()` L138150. | | `Deal.Contracts/Integrations/Models/MlStatsDto.cs` | create | `{Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox}` — локальная статистика 1:1 `ml_client.snapshot()` L138150. |
| `Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs` | create | Тело GET /api/ml/status `{Enabled, Service, Reachable, Stats}` 1:1 `ml_routes.py` L7075 (api-map §4.10 L363). | | `Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs` | create | Тело GET /api/ml/status `{Enabled, Service, Reachable, Stats}` 1:1 `ml_routes.py` L7075 (api-map §4.10 L363). |
| `Deal.Contracts/Integrations/Models/MlPredictResultDto.cs` | create | `{Take, Label?, Scores, Hits, Ready, Margin?, Terms[], Type?}` — 1:1 `model.py` predict (Ruling 5 L80). | | `Deal.Contracts/Integrations/Models/MlPredictResultDto.cs` | create | `{Take, Label?, Scores, Hits, Ready, Margin?, Terms[], Type?}` — 1:1 `model.py` predict (Ruling 5 L80). |
| `Deal.Contracts/Integrations/Models/MlResetResultDto.cs` | create | `{Ok, Error?}`; `Error``[JsonIgnore(WhenWritingNull)]` → успех сериализуется ровно `{"ok":true}` (ветка `{ok:false,error}` зарезервирована, план L347). | | `Deal.Contracts/Integrations/Models/MlResetResultDto.cs` | create | `{Ok, Error?}`; `Error``[JsonIgnore(WhenWritingNull)]` → успех сериализуется ровно `{"ok":true}` (ветка `{ok:false,error}` зарезервирована, план L347). |
| `Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs` | create | Типизация поля `type` предсказания `{Take, Label:"hire"\|"order", Value, Margin}` (эталон `model.py` L233–238; в этапе 2 всегда null). | | `Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs` | create | Типизация поля `type` предсказания `{Take, Label:"hire"\|"order", Value, Margin}` (эталон `model.py` L233–238; в этапе 2 всегда null). |
| `Deal.Infrastructure/Integrations/LocalMlClient.cs` | create | Заглушка Ruling 5: детерминированная. `StatusAsync` — reachable=true, service {ready:false, classes:{}, learned:0, eval 0/0/0}; счётчики mlDecisions/aiDecisions и mlEnabled — из KV settings (Ruling 1); learning/outbox=0. `PredictAsync` — фиксированный «не уверен» (все 8 полей). `ResetAsync``{ok:true}`, KV не трогает (прототип чистит только outbox). | | `Deal.Infrastructure/Integrations/LocalMlClient.cs` | create | Заглушка Ruling 5: детерминированная. `StatusAsync` — reachable=true, service {ready:false, classes:{}, learned:0, eval 0/0/0}; счётчики mlDecisions/aiDecisions и mlEnabled — из KV settings (Ruling 1); learning/outbox=0. `PredictAsync` — фиксированный «не уверен» (все 8 полей). `ResetAsync``{ok:true}`, KV не трогает (прототип чистит только outbox). |
| `Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealIntegrations()``AddScoped<IMlClient, LocalMlClient>` (по образцу AddDealSecurity). | | `Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealIntegrations()``AddScoped<IMlClient, LocalMlClient>` (по образцу AddDealSecurity). |
| `Deal.Api/Endpoints/MlEndpoints.cs` | create | `MapMlEndpoints` (`/api/ml/*`, тег "ml"): GET status → MlStatusResponseDto; POST reset → `{ok:true}`; POST predict `{text}` — trim <2 → 400 «Введите текст», ответ `{text:<первые 200>, take, label, scores, hits, ready, margin, terms, type}`; POST candidates → `{items:[]}`; POST apply → 404 «Исходное сообщение не найдено». 401-гейт {detail}, резолв IMlClient через RequestServices после гейта (паттерн SettingsEndpoints). | | `Deal.Api/Endpoints/MlEndpoints.cs` | create | `MapMlEndpoints` (`/api/ml/*`, тег "ml"): GET status → MlStatusResponseDto; POST reset → `{ok:true}`; POST predict `{text}` — trim <2 → 400 «Введите текст», ответ `{text:<первые 200>, take, label, scores, hits, ready, margin, terms, type}`; POST candidates → `{items:[]}`; POST apply → 404 «Исходное сообщение не найдено». 401-гейт {detail}, резолв IMlClient через RequestServices после гейта (паттерн SettingsEndpoints). |
| `Deal.Api/Endpoints/MlPredictRequest.cs`, `MlCandidatesRequest.cs`, `MlApplyRequest.cs` | create | Тела POST (как LoginRequest). | | `Deal.Api/Endpoints/MlPredictRequest.cs`, `MlCandidatesRequest.cs`, `MlApplyRequest.cs` | create | Тела POST (как LoginRequest). |
| `Deal.Api/Http/EndpointResults.cs` | modify | Добавлен `NotFound(detail)` (404 + {detail}, Ruling 10). | | `Deal.Api/Http/EndpointResults.cs` | modify | Добавлен `NotFound(detail)` (404 + {detail}, Ruling 10). |
| `Deal.Api/Program.cs` | modify | `AddDealIntegrations()` + `app.MapMlEndpoints()`. | | `Deal.Api/Program.cs` | modify | `AddDealIntegrations()` + `app.MapMlEndpoints()`. |
| `tests/…/Deal.Tests.Unit.csproj`, `LocalMlClientTests.cs` | modify/create | +13 тестов. | | `tests/…/Deal.Tests.Unit.csproj`, `LocalMlClientTests.cs` | modify/create | +13 тестов. |
## Границы и решения ## Границы и решения
- **`StatusAsync` возвращает полный `MlStatusResponseDto`** (включая enabled/stats), а не только статус сервиса: план предписывает заглушке самой читать счётчики «из KV settings» (Files-список Task 9 L342–344) — как прототип: `ml_routes.ml_status` + `ml_client.snapshot()` живут в одном модуле-клиенте. Эндпоинт — тонкий passthrough. На этапе 6 gRPC-адаптер собирает ответ так же (меняется только «service»-часть на сетевой вызов). - **`StatusAsync` возвращает полный `MlStatusResponseDto`** (включая enabled/stats), а не только статус сервиса: план предписывает заглушке самой читать счётчики «из KV settings» (Files-список Task 9 L342–344) — как прототип: `ml_routes.ml_status` + `ml_client.snapshot()` живут в одном модуле-клиенте. Эндпоинт — тонкий passthrough. На этапе 6 gRPC-адаптер собирает ответ так же (меняется только «service»-часть на сетевой вызов).
- **Deal.Contracts остался без зависимостей**: в контракте ML нет TenantId/SharedKernel — ссылку на SharedKernel добавлять не потребовалось (проверено: Contracts.csproj без ProjectReference; см. Ruling 4). Исключение контрактных моделей покрыто «модель в отдельном файле» (1 тип = 1 файл). - **Deal.Contracts остался без зависимостей**: в контракте ML нет TenantId/SharedKernel — ссылку на SharedKernel добавлять не потребовалось (проверено: Contracts.csproj без ProjectReference; см. Ruling 4). Исключение контрактных моделей покрыто «модель в отдельном файле» (1 тип = 1 файл).
- **Два record-DTO сверх явного списка плана** (Files-список содержит «Models/*.cs»): `MlResetResultDto` (необходим как возврат ResetAsync) и `MlTypeDecisionDto` (типизация `Type?` предсказания вместо не типизированного object). - **Два record-DTO сверх явного списка плана** (Files-список содержит «Models/*.cs»): `MlResetResultDto` (необходим как возврат ResetAsync) и `MlTypeDecisionDto` (типизация `Type?` предсказания вместо не типизированного object).
- **candidates/apply «честно пустые» по плану**: candidates — `{items:[]}` (телеграм-сообщений нет до этапа 6, L350–351); apply — всегда 404 «Исходное сообщение не найдено» (нет таблиц leads/messages до этапов 3/6, ветка skip — этап 6, L352353). ml/learn, ml/flush НЕ реализованы (фронт не вызывает — api-map п.9 L399). - **candidates/apply «честно пустые» по плану**: candidates — `{items:[]}` (телеграм-сообщений нет до этапа 6, L350–351); apply — всегда 404 «Исходное сообщение не найдено» (нет таблиц leads/messages до этапов 3/6, ветка skip — этап 6, L352353). ml/learn, ml/flush НЕ реализованы (фронт не вызывает — api-map п.9 L399).
- **Predict-ответ** — анонимный объект эндпоинта `{text, ...flatten}` (1:1 с python `{"text": text[:200], **result}`); text эхом = полный trim-нутый ввод (усечение только >200 символов, как `text[:200]`). - **Predict-ответ** — анонимный объект эндпоинта `{text, ...flatten}` (1:1 с python `{"text": text[:200], **result}`); text эхом = полный trim-нутый ввод (усечение только >200 символов, как `text[:200]`).
- **Очередь обучения**: reset не пишет/не чистит KV-счётчики (в прототипе чистится только ml_outbox — таблицы в этапе 2 нет, Ruling 5 L8182); счётчики mlDecisions/aiDecisions в статусе читаются, владельцы записи — этапы 3/4/6. - **Очередь обучения**: reset не пишет/не чистит KV-счётчики (в прототипе чистится только ml_outbox — таблицы в этапе 2 нет, Ruling 5 L8182); счётчики mlDecisions/aiDecisions в статусе читаются, владельцы записи — этапы 3/4/6.
- **Ошибки тела**: predict с текстом <2 символов после trim — 400 `{"detail":"Введите текст"}` (ровно как FastAPI); повреждённые KV-строки (mlEnabled/mlDecisions) — мягкий дефолт (как SettingsService); mlEnabled=false даёт enabled:false, отсутствие/не-bool — true (семантика `is not False`). - **Ошибки тела**: predict с текстом <2 символов после trim — 400 `{"detail":"Введите текст"}` (ровно как FastAPI); повреждённые KV-строки (mlEnabled/mlDecisions) — мягкий дефолт (как SettingsService); mlEnabled=false даёт enabled:false, отсутствие/не-bool — true (семантика `is not False`).
## Тесты (13 новых; всего 161 PASS) ## Тесты (13 новых; всего 161 PASS)
`LocalMlClientTests`: статус без настроек (все поля Ruling 5: enabled true, reachable true, service не готов, stats нули, outbox 0); счётчики из KV (ml=7/ai=3); mlEnabled false/true/не-bool/повреждён → disabled/enabled/дефолт; повреждённый счётчик → 0; predict неготовой модели — все 8 полей фиксированы; reset → ok:true и KV не тронут; wire-тесты: статус (ключи enabled/service/reachable/stats + вложенные service.eval/stats), predict (точная JSON-строка 1:1), reset (ровно `{"ok":true}` без error). `LocalMlClientTests`: статус без настроек (все поля Ruling 5: enabled true, reachable true, service не готов, stats нули, outbox 0); счётчики из KV (ml=7/ai=3); mlEnabled false/true/не-bool/повреждён → disabled/enabled/дефолт; повреждённый счётчик → 0; predict неготовой модели — все 8 полей фиксированы; reset → ok:true и KV не тронут; wire-тесты: статус (ключи enabled/service/reachable/stats + вложенные service.eval/stats), predict (точная JSON-строка 1:1), reset (ровно `{"ok":true}` без error).
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-9-curl-acceptance.sh/.log, тело predict — UTF-8 файл task-9-predict.json) ## Приёмка (curl :5080, admin/admin; скрипт + лог: task-9-curl-acceptance.sh/.log, тело predict — UTF-8 файл task-9-predict.json)
1. GET /api/ml/status без куки → 401 `{"detail":"Требуется авторизация"}`. 1. GET /api/ml/status без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → status: `{"enabled":true,"service":{"ready":false,"classes":{},"learned":0,"eval":{"count":0,"correct":0,"accuracy":0}},"reachable":true,"stats":{"ml":0,...,"outbox":0}}` (форма §4.10 1:1). 2. login → status: `{"enabled":true,"service":{"ready":false,"classes":{},"learned":0,"eval":{"count":0,"correct":0,"accuracy":0}},"reachable":true,"stats":{"ml":0,...,"outbox":0}}` (форма §4.10 1:1).
3. PATCH `{"mlEnabled":false}` → status `enabled:false`; PATCH true → `enabled:true` (KV живьём). 3. PATCH `{"mlEnabled":false}` → status `enabled:false`; PATCH true → `enabled:true` (KV живьём).
4. predict `{"text":"x"}` → 400 `{"detail":"Введите текст"}`. 4. predict `{"text":"x"}` → 400 `{"detail":"Введите текст"}`.
5. predict с текстом → 200: `text` эхом (кириллица без \u), `take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null`. 5. predict с текстом → 200: `text` эхом (кириллица без \u), `take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null`.
6. reset → `{"ok":true}` (ключа error нет); candidates → `{"items":[]}`; apply → 404 «Исходное сообщение не найдено». 6. reset → `{"ok":true}` (ключа error нет); candidates → `{"items":[]}`; apply → 404 «Исходное сообщение не найдено».
7. psql: в схеме тенанта таблиц ml_outbox/learning_log НЕТ (только settings + история миграций). 7. psql: в схеме тенанта таблиц ml_outbox/learning_log НЕТ (только settings + история миграций).
8. logout → status/reset 401. Итог: **PASS=31 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен). 8. logout → status/reset 401. Итог: **PASS=31 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания ## Concerns / замечания
1. **Wire числа**: `"accuracy":0` (STJ) против `0.0` у python json.dumps — валидный JSON, фронт читает `ev.accuracy || 0` (applyMlStatus) — косметика (как E-нотация курсов в Task 8). 1. **Wire числа**: `"accuracy":0` (STJ) против `0.0` у python json.dumps — валидный JSON, фронт читает `ev.accuracy || 0` (applyMlStatus) — косметика (как E-нотация курсов в Task 8).
2. **Typed-body binding**: тело POST (predict/candidates/apply) биндится до тела обработчика (паттерн LoginRequest) — невалидный JSON даст 400 фреймворка раньше 401-гейта; фронт шлёт валидный JSON только с сессией — приемлемо. 2. **Typed-body binding**: тело POST (predict/candidates/apply) биндится до тела обработчика (паттерн LoginRequest) — невалидный JSON даст 400 фреймворка раньше 401-гейта; фронт шлёт валидный JSON только с сессией — приемлемо.
3. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/404/400-ветки и wire эндпоинтов покрыты curl-приёмкой; юнит-уровень — LocalMlClient + wire-тесты DTO. 3. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/404/400-ветки и wire эндпоинтов покрыты curl-приёмкой; юнит-уровень — LocalMlClient + wire-тесты DTO.
4. **Contracts без TenantId** — ссылка на SharedKernel не понадобилась (см. выше); если этап 6 добавит в контракт tenant-параметры — ссылка появится тогда. 4. **Contracts без TenantId** — ссылка на SharedKernel не понадобилась (см. выше); если этап 6 добавит в контракт tenant-параметры — ссылка появится тогда.
## Проверки ## Проверки
``` ```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 161, пройдено 161, пропущено 0 (было 148, +13) dotnet test Deal.sln → всего 161, пройдено 161, пропущено 0 (было 148, +13)
sh task-9-curl-acceptance.sh → PASS=31 FAIL=0 (лог task-9-curl-acceptance.log) sh task-9-curl-acceptance.sh → PASS=31 FAIL=0 (лог task-9-curl-acceptance.log)
``` ```

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