SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Discovery — финальная волна правок: отчёт
|
||||
|
||||
**Статус: ГОТОВО** — все findings закрыты, проверки (компиляция, сборки, смоук) зелёные.
|
||||
|
||||
## Что исправлено
|
||||
|
||||
| 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`. |
|
||||
| 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`). |
|
||||
| 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)`. |
|
||||
| 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 значения меняются местами (как делает фронт). |
|
||||
| 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` (счётчик на задаче). |
|
||||
|
||||
## Проверки (команды и вывод)
|
||||
|
||||
1. Компиляция бэкенда:
|
||||
```
|
||||
$ 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/db.py backend/app/routers/settings_routes.py
|
||||
PY_COMPILE_OK
|
||||
```
|
||||
2. Сборка образа (обязательно после правок бэкенда/db.py):
|
||||
```
|
||||
$ docker compose build app
|
||||
[+] build 1/1
|
||||
✔ Image telbase-app Built 6.4s
|
||||
```
|
||||
(внутри образа повторно собран и фронтенд: `vite build` → 42 modules transformed, ok)
|
||||
3. Сборка фронтенда:
|
||||
```
|
||||
$ cd /c/telbase/frontend && npm run build
|
||||
vite v6.4.3 building for production...
|
||||
✓ 42 modules transformed.
|
||||
✓ built in 1.40s
|
||||
```
|
||||
4. Смоук на временной БД в контейнере (все Telegram-вызовы замоканы):
|
||||
```
|
||||
$ 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
|
||||
A_REJECT_IDEMPOTENT_OK # mark_rejected повторно: rejected=1, 1 reject-лог, 1 запись ЧС
|
||||
B_JOIN_FAILURES_OK # join_failures 1→2, на 3-й неудаче кандидат удалён + skip «3 попытки»
|
||||
B_JOIN_RECHECK_OK # задача на паузе во время delay → join не вызывается (action none)
|
||||
C_SEARCH_ERROR_TICK_OK # ошибка поиска: tick жив (action error), searchIdx не двигается
|
||||
D_AUTOJOIN_OK # успешный авто-join: joined/auto_joined, dialogs(monitor), backfill вызван
|
||||
LR_FIX_SMOKE_OK
|
||||
```
|
||||
Временный скрипт `data/lr_fix_smoke.py` удалён после прогона; `progress.md` не трогался.
|
||||
|
||||
## Concerns
|
||||
|
||||
1. Ветка flood-ошибки поиска и реальные сетевые ошибки join офлайн не воспроизводятся (нужен живой аккаунт); покрыты код-ревью и логикой (в `tg.discovery_join` flood фиксируется и пробрасывается, worker дублирует `note_flood` идемпотентно).
|
||||
2. При отсеве «личный чат/бот» пишется skip-лог на каждого человека в выдаче поиска — история задачи может стать многословной на «широких» ключах (по ТЗ-рекомендации инструкции; при желании можно убрать лог, оставив continue).
|
||||
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 чисты).
|
||||
5. `join_failures` не сбрасывается при переводе кандидата обратно в review после удачной паузы — не требуется: при повторном добавлении (после rejected) запись создаётся заново с `DEFAULT 0`, а успешный join переводит кандидата в `joined`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Discovery — scoped-ревью фикс-волны: вердикт и доработки
|
||||
|
||||
**Вердикт ревьюера: Ready — With fixes** (все 8 заявленных фиксов I1–I4/M1–M5/m1–m6 подтверждены код-ревью; 2 Important + 4 Minor).
|
||||
Смоук-прогон новых веток после правок — зелёный (A–E), образ пересобран, контейнер поднят, API 200.
|
||||
|
||||
## Findings ревьюера и что сделано
|
||||
|
||||
### Important
|
||||
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` + лог «ключ пропущен»), счётчик ошибок сбрасывается при успешном поиске.
|
||||
2. **Re-check после wait_join_delay без BanGuard** (`_join_step`) — за паузу 50–70 с пользователь мог нажать стоп-кран / случиться флуд, а pending-join всё равно выполнялся.
|
||||
→ **Исправлено**: в условие после паузы добавлено `not ban_guard.can_auto_join()` (покрывает стоп-кран, flood дня и суточный лимит).
|
||||
|
||||
### Minor
|
||||
3. **Single-key PATCH ломает инвариант min ≤ max пауз** — `PATCH {discJoinDelayMax: 5}` при сохранённом min=600 давал инверсию → `random.uniform` падал на каждом join-тике.
|
||||
→ **Исправлено** в двух местах: `settings_routes.patch_settings` клампит одиночный конец интервала относительно сохранённого другого; `ban_guard.wait_join_delay` защитно меняет концы местами (и выходит без паузы, если обе настройки 0).
|
||||
4. **UPDATE join_failures / delete на 3-й неудаче без ре-валидации status='review'** — узкая гонка: человека отклонил кандидата между re-read и падением join.
|
||||
→ **Исправлено**: перед инкрементом счётчика статус перечитывается (`row_now`); UPDATE идёт с `AND status='review'`; при выходе из review воркер выходит молча, не трогая запись.
|
||||
5. **Ручной join в API ждал inline-backfill ~15–30 с** (`backfill_dialog` спит 1.5–3 с/сообщение) — кнопка «Вступить» висела, прокси с коротким таймаутом показал бы ложную ошибку.
|
||||
→ **Исправлено**: `POST /candidates/{id}/join` запускает backfill в фоне через `_spawn(_backfill_quiet(...))` (паттерн set_monitor_all), ответ API быстрый, ошибки backfill не роняют запрос.
|
||||
6. **Skip-лог на каждого человека в поиске** — известный minor из отчёта фиксера (concern #2). Оставлен как есть: по одному логу на источник информативно для истории задачи; при желании можно агрегировать («пропущено личных чатов: N») — не критично.
|
||||
|
||||
## Smoke новых веток (временный скрипт в data/, удалён после прогона)
|
||||
|
||||
```
|
||||
A_FLOOD_GATE_OK # flood_today -> tick none, search не вызван
|
||||
B_PAUSE_DURING_DELAY_OK # стоп-кран во сне -> join не выполнен, кандидат review
|
||||
C_JOIN_FAILURES_REVIEW_GONE_OK # кандидат rejected во время падения join: счётчик 0, запись жива
|
||||
D_INVERTED_DELAYS_OK # min>max: wait_join_delay не падает (swap)
|
||||
E_SEARCH_KEY_SKIP_OK # 3 ошибки ключа -> ключ пропущен, searchDone, лог «ключ пропущен»
|
||||
LR_REVIEW_FIX_SMOKE_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).
|
||||
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":[]}`.
|
||||
|
||||
## Осталось
|
||||
- Живой E2E с Telegram-аккаунтом (шаги в task-10-report.md §«Осталось для ручной проверки») — вместе с пользователем: поиск → кандидаты → вступить/отклонить → авто-вступление с паузами и расходом лимита. Реальные flood/форумы офлайн не воспроизводятся.
|
||||
@@ -0,0 +1,28 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-04-channel-discovery.md
|
||||
|
||||
Адаптация процесса: проект НЕ git-репозиторий (рабочее дерево C:\telbase, деплой docker compose).
|
||||
Вместо коммитов фиксируем затронутые файлы и результат проверок; вместо git-диффов ревьюер читает
|
||||
файлы из brief. Рабочая папка плана: .superpowers/sdd/channel-discovery/
|
||||
|
||||
Pre-flight правки плана (сделаны контроллером до старта):
|
||||
- Task 3: добавлены недостающие интерфейсы delete_candidate() и advance_search() (Task 6 на них ссылается).
|
||||
- Task 6: унифицированы метки недоступной истории (закрытая группа/канал — история скрыта; канал — история недоступна).
|
||||
- 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 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 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 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 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 10: complete (ТЗ 4.9 + сборка/health; E2E с живым аккаунтом — за пользователем, шаги в task-10-report.md)
|
||||
- Фикс-волна (I1–I4/M1–M5/m1–m6): complete (final-fix-report.md; smoke зелёный)
|
||||
- Scoped-ревью фикс-волны: complete (final-review-report.md) — 2 Important + 4 Minor
|
||||
закрыты правками в discovery_worker/ban_guard/discovery_routes/settings_routes;
|
||||
повторный smoke A–E зелёный; образ пересобран, контейнер поднят, API 200.
|
||||
|
||||
Все 10 задач + фикс-волна выполнены, ревью чистое. Осталось: живой E2E с Telegram-аккаунтом (шаги в task-10-report.md).
|
||||
@@ -0,0 +1,91 @@
|
||||
### Task 1: Схема БД и настройки по умолчанию
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`)
|
||||
- Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`)
|
||||
- Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40).
|
||||
|
||||
- [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS disc_tasks (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR NOT NULL DEFAULT '',
|
||||
keywords VARCHAR NOT NULL DEFAULT '[]',
|
||||
min_subscribers INTEGER NOT NULL DEFAULT 0,
|
||||
lang VARCHAR NOT NULL DEFAULT 'ru',
|
||||
threshold INTEGER NOT NULL DEFAULT 40,
|
||||
sample_size INTEGER NOT NULL DEFAULT 10,
|
||||
plan_joins INTEGER NOT NULL DEFAULT 1,
|
||||
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
|
||||
search_idx INTEGER NOT NULL DEFAULT 0,
|
||||
search_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
found INTEGER NOT NULL DEFAULT 0,
|
||||
evaluated INTEGER NOT NULL DEFAULT 0,
|
||||
joined INTEGER NOT NULL DEFAULT 0,
|
||||
rejected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS disc_candidates (
|
||||
dialog_id VARCHAR PRIMARY KEY,
|
||||
task_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL DEFAULT '',
|
||||
username VARCHAR NOT NULL DEFAULT '',
|
||||
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
|
||||
hue VARCHAR NOT NULL DEFAULT '#666',
|
||||
participants INTEGER,
|
||||
lang_ru BOOLEAN,
|
||||
marks VARCHAR NOT NULL DEFAULT '[]',
|
||||
topics VARCHAR NOT NULL DEFAULT '[]',
|
||||
fit_ratio DOUBLE,
|
||||
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
|
||||
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_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 TABLE IF NOT EXISTS disc_blacklist (
|
||||
dialog_id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL DEFAULT '',
|
||||
reason VARCHAR NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS disc_log (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
task_id VARCHAR NOT NULL,
|
||||
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
|
||||
text VARCHAR NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`**
|
||||
|
||||
```python
|
||||
# поиск каналов (Discovery)
|
||||
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
|
||||
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
|
||||
"discJoinDelayMax": 70, # сек, верхняя граница
|
||||
"discEvalSample": 10, # размер выборки сообщений при оценке
|
||||
"discEvalThreshold": 40, # % подходящих сообщений
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **Step 4: Проверить**
|
||||
|
||||
```bash
|
||||
docker compose build app && docker compose up -d app
|
||||
```
|
||||
Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,91 @@
|
||||
# Task 1 — Отчёт: Схема БД и настройки по умолчанию (Discovery)
|
||||
|
||||
Статус: **DONE**
|
||||
|
||||
## Что сделано по шагам
|
||||
|
||||
### Step 1: Таблицы в `_SCHEMA` (`backend/app/db.py`)
|
||||
Добавлены перед таблицей `settings` (текст SQL — дословно из брифа):
|
||||
- `disc_tasks` — задачи поиска каналов (статус, прогресс, план);
|
||||
- `disc_candidates` — найденные кандидаты (оценки, статус);
|
||||
- `disc_blacklist` — чёрный список;
|
||||
- `disc_log` — лог событий по задаче;
|
||||
- индексы `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|...`) безопасны — в них `;` нет.
|
||||
|
||||
### Step 2: Настройки в `DEFAULT_SETTINGS` (`backend/app/constants.py`)
|
||||
Добавлены в конец словаря (дословно из брифа):
|
||||
```python
|
||||
# поиск каналов (Discovery)
|
||||
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
|
||||
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
|
||||
"discJoinDelayMax": 70, # сек, верхняя граница
|
||||
"discEvalSample": 10, # размер выборки сообщений при оценке
|
||||
"discEvalThreshold": 40, # % подходящих сообщений
|
||||
```
|
||||
|
||||
### Step 3: Публичные ключи и клампы (`backend/app/routers/settings_routes.py`)
|
||||
- В `_PUBLIC_INT` добавлены: `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`.
|
||||
- В `patch_settings` (по образцу существующих клампов `archiveAfterDays`/`minLen`):
|
||||
- `discJoinLimit` → 1..200;
|
||||
- `discJoinDelayMin` / `discJoinDelayMax` → 5..600;
|
||||
- `discEvalSample` → 3..30;
|
||||
- `discEvalThreshold` → 1..100.
|
||||
|
||||
### Step 4: Проверка
|
||||
Выполнена (вывод ниже).
|
||||
|
||||
## Изменённые файлы
|
||||
- `backend/app/db.py`
|
||||
- `backend/app/constants.py`
|
||||
- `backend/app/routers/settings_routes.py`
|
||||
|
||||
## Вывод проверок
|
||||
|
||||
### 1. `py_compile`
|
||||
```
|
||||
$ cd /c/telbase && python -m py_compile backend/app/db.py backend/app/constants.py backend/app/routers/settings_routes.py
|
||||
PY_COMPILE_OK # без ошибок
|
||||
```
|
||||
|
||||
### 2. Пересборка и запуск контейнера
|
||||
```
|
||||
$ docker compose build app && docker compose up -d app
|
||||
[+] build 1/1 ✔ Image telbase-app Built (первый запуск упал — см. Concern/Фикс)
|
||||
[+] up 3/3
|
||||
✔ Container leadradar-ml Running
|
||||
✔ Container leadradar-minio Running
|
||||
✔ Container leadradar Started
|
||||
```
|
||||
Логи контейнера после фикса:
|
||||
```
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Ctrl+C to quit)
|
||||
```
|
||||
|
||||
### 3. API
|
||||
```
|
||||
POST /api/auth/login {"login":"admin","password":"admin"} → login status: 200
|
||||
GET /api/settings →
|
||||
settings: {"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
|
||||
"discEvalSample": 10, "discEvalThreshold": 40}
|
||||
```
|
||||
Все пять ключей присутствуют и равны значениям по умолчанию. ✔
|
||||
|
||||
### Дополнительно
|
||||
- Попытка прямой проверки таблиц в DuckDB вторым процессом
|
||||
(`duckdb.connect(..., read_only=True)`) не удалась ожидаемо: DuckDB — одна запись,
|
||||
лок держит процесс приложения (`Conflicting lock is held... PID 1`). Косвенное
|
||||
подтверждение: `_SCHEMA` исполняется в `store.init()` при старте и падает loudly
|
||||
(первый запуск упал с Parser Error — это и выявило баг), а финальный старт чистый.
|
||||
|
||||
## Concerns
|
||||
1. **`;` в `--`-комментариях `_SCHEMA` опасен** — пайплайн `split(";")` режет схему
|
||||
посередине комментария. В добавленном блоке таких мест больше нет, но при будущих
|
||||
правках схемы стоит избегать `;` в комментариях.
|
||||
2. Клампы `discJoinDelayMin`/`discJoinDelayMax` независимы (оба 5..600) — нет
|
||||
перекрёстной проверки `min <= max` (в брифе её не требовалось).
|
||||
3. Прямой SQL-запрос к таблицам извне невозможен при запущенном приложении (лок
|
||||
DuckDB); фактическое наличие таблиц подтверждено чистым стартом и прохождением
|
||||
всей схемы в `store.init()`.
|
||||
@@ -0,0 +1,22 @@
|
||||
### Task 10: ТЗ, сборка и end-to-end проверка
|
||||
|
||||
**Files:**
|
||||
- Modify: `ТЗ.md` (раздел «Поиск и подключение каналов»)
|
||||
|
||||
- [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
|
||||
- [ ] **Step 2: Сборка и рестарт**:
|
||||
|
||||
```bash
|
||||
docker compose build app && docker compose up -d app
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**:
|
||||
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
|
||||
2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
|
||||
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
|
||||
4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
|
||||
5. «Отклонить» → уходит в чёрный список; повторно не находится.
|
||||
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 10 — Отчёт: ТЗ, сборка и health-проверка
|
||||
|
||||
## Статус
|
||||
Выполнено. ТЗ дополнено разделом 4.9; docker-образ собран и контейнер перезапущен; фронтенд собран; py_compile всех файлов Discovery — OK; health/settings проверены по HTTP (200). Живой E2E с кандидатами/вступлениями не выполнялся (нужен реальный Telegram-аккаунт) — ручные шаги перенесены в «Осталось для ручной проверки».
|
||||
|
||||
## Файлы
|
||||
- Изменён: `ТЗ.md` — добавлен подраздел `### **4.9. Поиск и подключение каналов (Discovery)**` (11 пунктов) между `4.8` и «5. Спецификация пайплайна обработки данных».
|
||||
|
||||
## Что сделано
|
||||
### Step 1: Раздел ТЗ (4.9)
|
||||
Структура файла: функциональные требования — секция 4.x (4.1–4.8), поэтому Discovery добавлен подразделом **4.9** (перед «5. Спецификация пайплайна»), в стиле остальных разделов (`### **4.N. …**` + маркированный список `> *`). Покрыто по спеке:
|
||||
- Подвкладка **«Поиск»** на экране «Каналы»; задача поиска: описание цели → генерация поисковых ключей ИИ (редактируются до старта) → **каскад фильтров** по нарастающей стоимости (поиск/дедупликация → «мы не состоим» → число участников → язык → оценка содержимого).
|
||||
- **Глобальное правило «мы не состоим»** — безусловное, для всех задач и всех этапов (поиск → оценка → вступление), повторная проверка перед join'ом.
|
||||
- **Метки**: «закрытая группа/канал», «форум», «не прочитано», «участники не подтверждены», «язык не подтверждён», «мало сообщений», «есть проходные темы»; «не подтверждено» = сигнал человеку, не пропуск.
|
||||
- Оценка содержимого с **профилем задачи** (без карточек/обучения ML); каналы/открытые группы — выборка до sampleSize, доля ≥ порога; открытая группа без чтения — на рассмотрение с меткой.
|
||||
- **Оценка по темам (форумы):** тема «подходит X из N», группа подходит при ≥1 проходной теме, превью тем; ≥3 содержательных сообщений для оценки, иначе метка «мало сообщений»; закрытые группы — сразу на рассмотрение с меткой.
|
||||
- **Review/join/reject + чёрный список**: «Вступить и мониторить» (monitor=1 + догон ~10 сообщений), «Отклонить» → чёрный список (исключение во всех задачах, снимается вручную), закрытые группы — ссылка `t.me/<username>` + авто-замечание при синхронизации.
|
||||
- **План задач и правило создания:** план 1–50; сумма планов активных задач (не `done/failed`) ≤ суточного лимита (по умолчанию 50); у активной задачи план не увеличить сверх свободного бюджета.
|
||||
- **Авто-вступление и квоты:** `autoJoin` на задачу; случайная пауза **50–70 с**, по одному действию; лимит **50 авто-вступлений/сутки общий на все задачи**; ручные — без квот; задача «выполнена» по плану, упор в бюджет — продолжение на следующий день.
|
||||
- **Анти-бан (BanGuard):** мягкие паузы поиска/чтения, `FloodWaitError` → пауза + остановка авто-вступлений до следующего дня, общий «стоп-кран»; лимит/интервалы — настройки UI.
|
||||
|
||||
### Step 2: Сборка и рестарт
|
||||
- `docker compose build app && docker compose up -d app` — образ `telbase-app` собран, контейнер `leadradar` пересоздан и поднят; `leadradar-ml`/`leadradar-minio` — running.
|
||||
- `cd frontend && npm run build` — без ошибок.
|
||||
- `python -m py_compile …` (все файлы брифa) — без ошибок.
|
||||
|
||||
## Вывод проверок
|
||||
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).
|
||||
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**.
|
||||
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 включён, БД-таблицы созданы.
|
||||
|
||||
## Осталось для ручной проверки (E2E из брифа Step 3 — нужен подключённый Telegram-аккаунт)
|
||||
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
|
||||
2. Дождаться кандидатов; убедиться, что текущие подписки и отклонённые не появляются (правило «мы не состоим» + чёрный список).
|
||||
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
|
||||
4. «Вступить и мониторить» → источник в «Каналах» (monitor on) и начинает давать карточки.
|
||||
5. «Отклонить» → источник в чёрном списке; повторно не находится.
|
||||
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита (50).
|
||||
|
||||
## Concerns
|
||||
1. ТЗ-раздел написан кратко по спеке — детальные значения (таблицы полей задачи/БД, схемы API) сознательно не дублируются: в ТЗ это функциональный обзор, а точные контракты зафиксированы в дизайн-доке (ссылки на параметры `discJoinLimit`, `sampleSize`, `autoJoin` и т.п. даны).
|
||||
2. «На рассмотрение»/вступление/чёрный список проверены только на уровне API-контракта (пустые списки 200) и логов запуска — поведение с живым Telegram-аккаунтом (поиск реально возвращает кандидатов, join проходит) остаётся за ручной проверкой пользователя.
|
||||
3. `discPaused` — рантайм-настройка без дефолта в `DEFAULT_SETTINGS`; в GET /api/settings присутствует как `false` (bool), что подтверждено.
|
||||
4. `progress.md` не трогался.
|
||||
@@ -0,0 +1,33 @@
|
||||
### Task 2: BanGuard (квоты, паузы, flood)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/ban_guard.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store`, настройки из Task 1.
|
||||
- Produces:
|
||||
- `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`).
|
||||
- `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
|
||||
- `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 flood_today() -> bool`
|
||||
- `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`)
|
||||
- `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 2: Проверить на временной БД в контейнере**
|
||||
|
||||
```bash
|
||||
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.services import ban_guard as bg
|
||||
assert bg.can_auto_join() is True
|
||||
assert bg.joins_today_auto() == 0
|
||||
bg.note_flood(); assert bg.flood_today() is True
|
||||
bg.set_global_pause(True); assert bg.can_auto_join() is False
|
||||
print('BANGUARD OK')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -0,0 +1,84 @@
|
||||
# Task 2 — Отчёт: BanGuard (квоты, паузы, flood)
|
||||
|
||||
Статус: **DONE**
|
||||
|
||||
## Что сделано по шагам
|
||||
|
||||
### Step 1: Модуль `backend/app/services/ban_guard.py` (создан)
|
||||
Реализован по интерфейс-спеке брифа (в брифе «дословного» кода нет — только сигнатуры
|
||||
и поведение, см. Concerns #1):
|
||||
|
||||
- `joins_today_auto() -> int` — `count(*)` из `disc_log` по `event='join_auto'`
|
||||
с `created_at >= начало текущих UTC-суток` (`datetime.now(timezone.utc)
|
||||
.replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
|
||||
- `can_auto_join() -> bool` — `joins_today_auto() < discJoinLimit` И `not flood_today()`
|
||||
И `not global_paused()`.
|
||||
- `async wait_join_delay() -> None` — `asyncio.sleep(random.uniform(discJoinDelayMin,
|
||||
discJoinDelayMax))` (значения из `store.get_setting`).
|
||||
- `note_flood() -> None` — `store.set_setting("discFloodDay", <start_of_day_ms>)`.
|
||||
- `flood_today() -> bool` — `discFloodDay == start_of_day_ms` (вчерашний флуд-день
|
||||
автоматически «протухает» в полночь UTC).
|
||||
- `global_paused() -> bool` / `set_global_pause(v: bool) -> None` — setting `discPaused`.
|
||||
- `search_pause() -> float` — `random.uniform(2.0, 4.0)`.
|
||||
|
||||
Детали реализации:
|
||||
- Хелпер `_start_of_day_ms()` общий для квоты, флуда и паузы.
|
||||
- Ключи настроек вынесены в модульные константы (`_KEY_*`) — в коде нет «голых» строк.
|
||||
- `discPaused`/`discFloodDay` не в `DEFAULT_SETTINGS` (рантайм-настройки): `get_setting`
|
||||
возвращает `None`, который трактуется как «не взведено» (`False`/`0`).
|
||||
- Стиль модуля — как в соседних сервисах: `from ..db import store`, русские докстринги,
|
||||
`from __future__ import annotations`.
|
||||
|
||||
### Step 2: Проверка на временной БД в контейнере
|
||||
Первая попытка упала: `ImportError: cannot import name 'ban_guard'` — код копируется в
|
||||
образ при сборке (`backend/Dockerfile`: `COPY backend/app ./app`), исходники не
|
||||
монтируются, а образ был собран до создания файла. Выполнена пересборка
|
||||
`docker compose build app` (2.6s, слой кода — единственный не из кэша), затем команда
|
||||
из брифа прошла (вывод ниже).
|
||||
|
||||
## Изменённые файлы
|
||||
- `backend/app/services/ban_guard.py` (создан)
|
||||
|
||||
## Вывод проверок
|
||||
|
||||
### 1. `py_compile`
|
||||
```
|
||||
$ cd /c/telbase && python -m py_compile backend/app/services/ban_guard.py
|
||||
COMPILE_OK # без ошибок
|
||||
```
|
||||
|
||||
### 2. Временная БД в контейнере (команда из брифа Step 2)
|
||||
```
|
||||
$ docker compose build app # необходимо, т.к. код запекается в образ
|
||||
$ 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.services import ban_guard as bg
|
||||
assert bg.can_auto_join() is True
|
||||
assert bg.joins_today_auto() == 0
|
||||
bg.note_flood(); assert bg.flood_today() is True
|
||||
bg.set_global_pause(True); assert bg.can_auto_join() is False
|
||||
print('BANGUARD OK')
|
||||
"
|
||||
BANGUARD OK
|
||||
```
|
||||
Все 4 assert'а из брифа прошли. ✔
|
||||
|
||||
### Дополнительно
|
||||
- Статическая проверка (diagnostics Zed): ошибок и предупреждений нет.
|
||||
|
||||
## Concerns
|
||||
1. **«Весь код — в брифе» — фактически неверно**: в `task-2-brief.md` (и в плане)
|
||||
нет ни одного блока кода модуля, только интерфейс-спека (~10 сигнатур с описанием
|
||||
поведения) и команда проверки. «Транскрибировать дословно» было нечего; модуль
|
||||
реализован по спеке. Очевидных ошибок/противоречий в спеке не нашёл — править было
|
||||
нечего. Если планировался эталонный код — его нужно добавить в бриф.
|
||||
2. `wait_join_delay()` и `search_pause()` не покрыты проверкой Step 2 (оба — случайные
|
||||
паузы; дефолт паузы вступления 50–70 сек, поэтому в проверку они не входили).
|
||||
Логика тривиальная (`random.uniform` + `asyncio.sleep`), но «живого» прогона нет.
|
||||
3. `can_auto_join()` при `discJoinLimit <= 0` всегда `False` (осторожная сторона:
|
||||
лимит 0 = «не вступать»).
|
||||
4. Клампы пауз (5..600) независимы — `min > max` теоретически возможно через UI
|
||||
(унаследованный concern из Task 1; в `wait_join_delay` тогда диапазон
|
||||
«вывернется», но не упадёт). Перекрёстную проверку бриф не требовал.
|
||||
5. Для проверок последующих задач нужна пересборка образа после каждого изменения
|
||||
backend-кода (образ не монтирует исходники).
|
||||
@@ -0,0 +1,29 @@
|
||||
### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store` (таблицы Task 1).
|
||||
- Produces (все синхронные):
|
||||
- `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(...)`.
|
||||
- `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` (удалить задачу и её кандидатов)
|
||||
- `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused
|
||||
- `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` с причиной.
|
||||
- `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected
|
||||
- `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата
|
||||
- `set_candidate_status(dialog_id, status)` + лог
|
||||
- `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки)
|
||||
- `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_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_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]`
|
||||
|
||||
- [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами).
|
||||
- [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs` → `add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,111 @@
|
||||
# Task 3 — Отчёт: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
|
||||
|
||||
## Статус
|
||||
✅ Реализовано и проверено (py_compile + сценарий на временной БД в контейнере).
|
||||
|
||||
## Файлы
|
||||
- Создан: `backend/app/services/discovery.py` — все функции из брифа (см. «Interfaces» брифа), синхронные, поверх `store` из `db.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)`.
|
||||
- **Бюджет**: `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)`.
|
||||
- **Переходы**: `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)`.
|
||||
|
||||
Ключевые решения (задокументированы в докстринге модуля):
|
||||
- Все обращения к БД — `store.*` с параметрами; JSON-поля `keywords/marks/topics` — `json.dumps(..., ensure_ascii=False)`/`json.loads`.
|
||||
- Время — `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()` напрямую.
|
||||
- `set_candidate_status` разрешает `new/review` (лог `review`); `joined/rejected` — только через `mark_joined`/`mark_rejected` (там счётчики/чёрный список/лог).
|
||||
- `start_task`: keywords непустые (иначе `ValueError`); из `done/failed` — сброс прогресса поиска, из `paused` — продолжение без сброса.
|
||||
- `advance_search`: `search_idx += 1`; `search_idx >= len(keywords)` → `search_done = True`.
|
||||
- `delete_candidate` — идемпотентная (skip-ветки воркера); перезапись «устаревшего» rejected-кандидата новым при `add_candidate` (после `remove_blacklist`), т.к. `dialog_id` — PK.
|
||||
|
||||
### Step 2: Проверка на временной БД в контейнере
|
||||
Код запекается в образ, поэтому перед прогоном: `docker compose build app` (кэш — сборка ~3 c).
|
||||
|
||||
Команда:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Сценарий (текст; временный файл `data/task3_check.py`, смонтирован в контейнер как `/data/task3_check.py`; после прогона удалён):
|
||||
```python
|
||||
from app.db import store
|
||||
from app.services import discovery as d
|
||||
|
||||
store.init()
|
||||
|
||||
# ── 1. Бюджет plan_joins: 25 ок; 30 поверх 25 -> ValueError (лимит 50) ──────
|
||||
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)
|
||||
try:
|
||||
d.create_task({"name": "Задача B", "planJoins": 30})
|
||||
raise AssertionError("ожидался ValueError по бюджету")
|
||||
except ValueError as exc:
|
||||
assert "исчерпан" in str(exc), exc
|
||||
assert len(d.list_tasks()) == 1
|
||||
|
||||
# ── 2. add_candidate: источник уже в dialogs -> None + лог skip ─────────────
|
||||
store.execute(
|
||||
"INSERT INTO dialogs(id, name, handle, kind, hue, updated_at) VALUES (?, ?, '', 'чат', '#666', ?)",
|
||||
["src_we_are_in", "Уже наш канал", 1],
|
||||
)
|
||||
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"]
|
||||
assert any("уже мониторится" in r["text"] for r in skip_log), d.task_log(t1["id"])
|
||||
assert d.get_task(t1["id"])["found"] == 0 # skip не считается найденным
|
||||
|
||||
# ── 3. mark_rejected -> чёрный список; повторный add_candidate -> None ──────
|
||||
cand = d.add_candidate(t1["id"], "ch_bad", "Плохой канал", "bad_ch", "channel", "#f00")
|
||||
assert cand is not None and cand["status"] == "new"
|
||||
assert d.get_task(t1["id"])["found"] == 1
|
||||
rej = d.mark_rejected("ch_bad", "спам")
|
||||
assert rej["status"] == "rejected"
|
||||
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.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"]
|
||||
assert any("чёрном списке" in r["text"] for r in skip2), d.task_log(t1["id"])
|
||||
|
||||
# ── 4. advance_search до конца ключей -> searchDone=True ────────────────────
|
||||
t = d.get_task(t1["id"])
|
||||
assert t["searchIdx"] == 0 and t["searchDone"] is False
|
||||
for _ in range(3):
|
||||
d.advance_search(t1["id"])
|
||||
t = d.get_task(t1["id"])
|
||||
assert t["searchIdx"] == 3 and t["searchDone"] is True, t
|
||||
|
||||
# ── доп. проверки целостности интерфейсов ───────────────────────────────────
|
||||
assert d.patch_task(t1["id"], {"minSubscribers": 500, "autoJoin": True})["minSubscribers"] == 500
|
||||
assert d.list_candidates(t1["id"], status="rejected")[0]["dialogId"] == "ch_bad"
|
||||
good = d.add_candidate(t1["id"], "ch_good", "Хор канал", "good_ch", "channel", "#0f0")
|
||||
assert good is not None
|
||||
assert d.mark_joined(good["dialogId"], auto=False)["status"] == "joined"
|
||||
assert d.get_task(t1["id"])["joined"] == 1
|
||||
assert [r["event"] for r in d.task_log(t1["id"])].count("join_manual") == 1
|
||||
|
||||
print("DISCOVERY 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`:
|
||||
- задача `planJoins=25` создана; вторая с `planJoins=30` → `ValueError` («Бюджет авто-вступлений исчерпан…»);
|
||||
- источник, вставленный в `dialogs`, → `add_candidate` вернул `None`, в `task_log` событие `skip` «уже мониторится», `found` не увеличен;
|
||||
- `mark_rejected` → статус `rejected`, кандидат в `list_blacklist()`, счётчик `rejected=1`; повторный `add_candidate` того же источника → `None` + лог `skip` «в чёрном списке»;
|
||||
- `advance_search` ×3 (3 ключа) → `searchIdx=3`, `searchDone=True`;
|
||||
- доп.: `patch_task` (minSubscribers/autoJoin), `list_candidates(status=…)`, `mark_joined(auto=False)` → `joined=1` + лог `join_manual`.
|
||||
3. Диагностика файла — без ошибок и предупреждений.
|
||||
|
||||
## Concerns
|
||||
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 понадобится «сырой» перевод — расширить функцию осознанно.
|
||||
3. `delete_task` дополнительно чистит `disc_log` задачи (в брифе — «задачу и её кандидатов»); чёрный список общий и не трогается.
|
||||
4. `add_candidate` инкрементит `found` только при успешном добавлении (skip-источники не считаются найденными).
|
||||
5. Для прогонов в контейнере нужен `docker compose build app` (код запекается в образ) и `MSYS_NO_PATHCONV=1` на Git Bash (иначе аргументы `/data/…` и `/tmp/…` конвертируются в Windows-пути).
|
||||
@@ -0,0 +1,19 @@
|
||||
### Task 4: Telegram-действия поиска (методы TelegramManager)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/telegram.py` (класс `TelegramManager`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `self.client`, `ban_guard`.
|
||||
- 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_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_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`.
|
||||
- `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 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,202 @@
|
||||
# Task 4 — Отчёт: Telegram-действия поиска (методы TelegramManager)
|
||||
|
||||
Статус: **DONE**
|
||||
|
||||
## Что сделано по шагам
|
||||
|
||||
### Step 1: Методы в `TelegramManager` (`backend/app/services/telegram.py`)
|
||||
Добавлены в конец класса (секция `# ── discovery ...`), рядом с `dialog_messages`:
|
||||
|
||||
- `discovery_search(q, limit=30) -> list[dict]` — `client(functions.contacts.SearchRequest(...))`;
|
||||
после запроса `await asyncio.sleep(ban_guard.search_pause())` (пауза «между поисками»).
|
||||
Возвращает `[{id, name, username, kind, hue}]`:
|
||||
- `id` — **подписанный** peer id (`utils.get_peer_id(entity)`), т.е. формат совпадает с
|
||||
`str(dlg.id)` в таблице `dialogs` (каналы `-100…`, базовые группы `-id`, люди `+id`).
|
||||
Это нужно для глобального правила «мы не состоим» (сравнение с `dialogs`).
|
||||
- `name` — `utils.get_display_name(entity)` (как `Dialog.name`), `kind` — `_kind_of`, `hue` — `dialog_hue`.
|
||||
- Результаты дедуплицируются по `id`.
|
||||
- `discovery_info(dialog_id) -> dict` — `{id, name, username, kind, hue, participants, is_forum}`:
|
||||
участники из `full_chat` (`GetFullChannelRequest` для каналов/супергрупп,
|
||||
`GetFullChatRequest` для базовых групп); при любой ошибке/недоступности — `participants=None`,
|
||||
исключение наружу не бросается.
|
||||
- `discovery_read(dialog_id, limit) -> dict` — `{"ok", "error", "messages":[{id, text, date_ms, topic_id, topic_title}]}`;
|
||||
для форумов — выборка по активным темам (`channels.getForumTopics` + чтение каждой темы),
|
||||
плоский список с `topic_id`/`topic_title`; для обычных источников оба поля = `None`.
|
||||
История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. Сообщения без
|
||||
текста (медиа/сервисные) пропускаются (как в `dialog_messages`/backfill). `limit <= 0` → пустой
|
||||
`ok` без сетевых вызовов. (правка тем форума — в «Fix round 1»)
|
||||
- `discovery_join(username) -> None` — `get_entity(username)` + `JoinChannelRequest`;
|
||||
`FloodWaitError` → `ban_guard.note_flood()` + `raise`. Пауза/квоты НЕ внутри — ручной join из API
|
||||
вне квот, `wait_join_delay()` перед авто-вступлением вызывает воркер (Task 6).
|
||||
Пустой username → `ValueError`. (правка — в «Fix round 1»)
|
||||
- `discovery_leave(dialog_id) -> None` — `LeaveChannelRequest`.
|
||||
- `add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — синхронный upsert в `dialogs`
|
||||
(`monitor=TRUE, backfilled=FALSE`, `ON CONFLICT DO UPDATE` — по образцу `set_monitor`/`_persist_dialogs`)
|
||||
+ `_reload_monitored()`. Без авто-логики (никакого `_spawn(backfill)` — как в брифе).
|
||||
|
||||
Импорты: `from telethon import ..., utils`, `FloodWaitError` (`telethon.errors.rpcerrorlist`),
|
||||
`functions` (`telethon.tl`), `from . import ban_guard`. `progress.md` не трогал.
|
||||
|
||||
### Step 2: Проверка
|
||||
Выполнена (см. ниже). Живых Telegram-вызовов не делалось (E2E — Task 10).
|
||||
|
||||
## Изменённые файлы
|
||||
- `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
|
||||
PY_COMPILE_OK # без ошибок
|
||||
```
|
||||
Дополнительно (без сети, на временной БД `LEADRADAR_DATA=/tmp/lr_t4*`):
|
||||
- импорт `from app.services import telegram, ban_guard` — `IMPORT_OK`, методы присутствуют;
|
||||
- офлайн-сценарии: `discovery_search` без клиента → `RuntimeError`; `discovery_info` без клиента →
|
||||
словарь с `participants=None, is_forum=False`; `discovery_read` → `no_history` (и `ok=True` при `limit<=0`);
|
||||
`discovery_join('')` → `ValueError`; `discovery_leave` без клиента → `RuntimeError`;
|
||||
`add_dialog_monitored` → строка `monitor=TRUE, backfilled=FALSE` в `dialogs` + `_monitored` обновлён;
|
||||
`dialog_messages` (существующий метод) не сломан — `OFFLINE_OK`.
|
||||
|
||||
## Concerns
|
||||
|
||||
### Доступность полей Telethon (проверено интроспекцией установленного telethon==1.37.0)
|
||||
- **participants**: авторитетный источник — `ChannelFull.participants_count` из
|
||||
`channels.GetFullChannelRequest` (каналы и супергруппы; работает для публичных каналов и без
|
||||
вступления). У самого `Channel` тоже есть `participants_count: Optional[int]`, но он не гарантирован,
|
||||
поэтому в коде берём полный чат. Для базовой группы `ChatFull.participants` имеет тип
|
||||
`ChatParticipants | ChatParticipantsForbidden` — считаем `len(participants.participants)`;
|
||||
`Forbidden`/ошибка → `None`. Для «чатов»-людей участников нет → `None`. Любая ошибка (приватный
|
||||
канал/группа без членства) → `None` по брифу. Такие кандидаты получат метку
|
||||
«участники не подтверждены» (Task 6) вместо пропуска.
|
||||
- **is_forum**: берётся с entity — `Channel.forum: Optional[bool]` (флаг конструктора `channel`).
|
||||
⚠️ У `ChannelFull` поля `forum` НЕТ (есть только `view_forum_as_messages` — личная настройка
|
||||
просмотра, не признак форума). Fallback: если entity пришло в min-форме без флага — `False`.
|
||||
- **topic_id / темы форума**: `Message.reply_to` → `MessageReplyHeader.reply_to_top_id` (в 1.37 поле
|
||||
есть), но для раскладки «по активным темам» (спека §6) плоской ленты недостаточно — чтение по
|
||||
темам через `channels.GetForumTopicsRequest` реализовано в Fix round 1 (см. ниже).
|
||||
|
||||
### Прочее
|
||||
1. **Кэш entity из поиска**: `discovery_search` делает `client.session.process_entities(found)` —
|
||||
иначе `discovery_info/read` по `dialog_id` не смогут резолвить кандидата до вступления (нет в
|
||||
dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта.
|
||||
2. **`discovery_join` и ручной join (Task 7)** — **закрыто в Fix round 1**: пауза убрана из
|
||||
`discovery_join` (ручной join — вне квот); `wait_join_delay()` перед авто-вступлением будет
|
||||
вызывать воркер (Task 6).
|
||||
3. **`add_dialog_monitored` не запускает backfill**: по брифу авто-логики нет (в отличие от
|
||||
`set_monitor`, где `_spawn(backfill_dialog)`). Строка остаётся `backfilled=FALSE`, и разбор
|
||||
последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании;
|
||||
если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызвать
|
||||
`tg.backfill_dialog(dialog_id)` явно (спека §7).
|
||||
4. **Ошибки поиска**: пауза стоит после успешного `contacts.search`; ошибки запроса (в т.ч.
|
||||
`FloodWaitError`) пробрасываются без `note_flood` (бриф связывает flood-обработку только с
|
||||
join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (события `flood`/`error`).
|
||||
5. **Юзеры в выдаче поиска**: `contacts.search` возвращает и людей (`_kind_of` → «чат»). По брифу
|
||||
не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало
|
||||
сообщений) — при желании Task 6 может отфильтровать их раньше.
|
||||
6. **Pyright-«шум»** в новых методах (`Entity | List[Entity]` в `JoinChannelRequest`, отсутствие
|
||||
`process_entities` в стабах сессии и т.п.) — тот же класс предупреждений, что и в существующем
|
||||
коде (`backfill_dialog`, `dialog_messages`); на рантайм не влияет, код следует стилю файла.
|
||||
|
||||
---
|
||||
|
||||
## Fix round 1
|
||||
|
||||
Правки по итогам ревью (только `backend/app/services/telegram.py`).
|
||||
|
||||
### 1) Пауза убрана из `discovery_join`
|
||||
- Удалён `await ban_guard.wait_join_delay()` из метода: ручной join из API (Task 7) — вне квот/пауз.
|
||||
- Внутри осталась только обработка `FloodWaitError` → `ban_guard.note_flood()` + `raise`.
|
||||
- Паузу перед авто-вступлением теперь вызывает воркер (Task 6): `await ban_guard.wait_join_delay()`
|
||||
непосредственно перед `tg.discovery_join(...)`. `ban_guard` в файле по-прежнему используется
|
||||
(`search_pause` в `discovery_search`, `note_flood` в `discovery_join`).
|
||||
|
||||
### 2) Форумные темы в `discovery_read`
|
||||
- Определение форума — `entity.forum`.
|
||||
- Если forum: `functions.channels.GetForumTopicsRequest(channel=entity, offset_date=0,
|
||||
offset_id=0, offset_topic=0, limit=5)` → `topics`; для каждого topic читается до
|
||||
`max(1, limit // len(topics))` последних сообщений через `client.get_messages(entity, limit=n,
|
||||
reply_to=topic.id)` (в 1.37 это `messages.GetRepliesRequest` — см. Concerns).
|
||||
- Возвращается плоский список `{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` больше
|
||||
не берётся — контракт брифа).
|
||||
- Безопасность: исключения в темах не пробрасываются — тема, которая не прочиталась,
|
||||
пропускается; если не собрано ни одного сообщения тем — fallback на обычное чтение ленты
|
||||
(General). Полный отказ и обычного чтения → `ok=False, error="no_history"`. Формат ответа
|
||||
сохранён: `{"ok", "error", "messages"}`.
|
||||
- Добавлены приватные хелперы: `_read_forum_topics(...)` (чтение тем) и
|
||||
`_discovery_message_item(...)` (общий фильтр непустого текста + сборка item для ленты и тем).
|
||||
|
||||
### Проверка Fix round 1
|
||||
```
|
||||
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
|
||||
PY_COMPILE_OK
|
||||
```
|
||||
Офлайн-смоук без сети (`LEADRADAR_DATA=/tmp/lr_t4e`): `discovery_read` без клиента → `no_history`,
|
||||
`limit<=0` → пустой `ok`; `discovery_join('')` → `ValueError`; `_discovery_message_item` фильтрует
|
||||
пустой текст и собирает поля `topic_id`/`topic_title` — `SMOKE_OK`. Живых Telegram-вызовов нет.
|
||||
|
||||
### Concerns (Fix round 1)
|
||||
1. Сигнатура `GetForumTopicsRequest` в 1.37: параметр называется `channel` (не `peer`), а
|
||||
параметра `offset` нет (есть `offset_date/offset_id/offset_topic`); вызываем с реальными именами.
|
||||
2. Поле темы в 1.37 — `ForumTopic.title` (не `top_title`); берём `topic.title`.
|
||||
3. `messages.GetHistoryRequest` в 1.37 не имеет `top_msg_id`; `client.get_messages(...,
|
||||
reply_to=topic_id)` реализован через `messages.GetRepliesRequest(peer, msg_id=topic_id)` — в
|
||||
Telegram сообщения темы форума являются «ответами» на её стартовое сообщение, поэтому это
|
||||
корректный способ чтения темы. Ручной fallback через `GetHistoryRequest(top_msg_id=…)` в 1.37
|
||||
невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента.
|
||||
4. Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на
|
||||
живом аккаунте в E2E (Task 10).
|
||||
|
||||
---
|
||||
|
||||
## Fix round 2
|
||||
|
||||
Правка по замечанию ревью (только `backend/app/services/telegram.py`).
|
||||
|
||||
### Изменение
|
||||
- В `_read_forum_topics` размер выборки на тему изменён с `max(1, limit // len(topics))`
|
||||
на `min(max(3, math.ceil(limit / len(topics))), 10)`:
|
||||
- минимум **3** сообщения на тему — иначе типичная тема не набирает порог
|
||||
«мало сообщений» (Task 5: `passed` требует ≥3 содержательных) и многотемные
|
||||
форумы почти всегда отсеивались бы как «мало подходящих»;
|
||||
- `ceil` вместо целочисленного деления — при `limit=10` и 5 темах теперь 3, а не 2;
|
||||
- cap **10** — не выкачиваем больше десятка на тему (sample_size ограничен 3..30,
|
||||
чтение по темам и так дороже плоской ленты).
|
||||
- Добавлен `import math`. Поведение без тем/с ошибками не менялось: пустой список тем и
|
||||
исключения по-прежнему ведут к fallback на обычное чтение ленты (General); суммарная
|
||||
выборка может слегка превышать `limit` — осознанно для форумов.
|
||||
|
||||
Примеры расчёта: limit=10/5 тем → 3 на тему; limit=10/1 тема → 10; limit=30/5 тем → 6;
|
||||
limit=30/10 тем → 3.
|
||||
|
||||
### Проверка
|
||||
```
|
||||
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
|
||||
PY_COMPILE_OK # без ошибок
|
||||
```
|
||||
|
||||
### Re-review (scoped, по текущему коду `_read_forum_topics`)
|
||||
|
||||
Вердикт: **ADDRESSED** — новых Critical/Important в фиксе нет.
|
||||
|
||||
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=30 / 10 тем: `ceil(3)=3` → 3 ✓
|
||||
- limit=30 / 5 тем: `ceil(6)=6` → 6 ✓
|
||||
Нижняя граница (3) и cap (10) на месте; `ceil` даёт int, деления на ноль нет —
|
||||
`if not topics: return out` (L780–781) стоит до расчёта.
|
||||
2. **Fallback и обработка ошибок не сломаны**: пустой список тем и исключение
|
||||
`getForumTopicsRequest` → `return out` → в `discovery_read` пустой результат ведёт к
|
||||
обычной ленте (General) (L746–747); ошибка чтения отдельной темы → `continue`
|
||||
(L793–795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743–745).
|
||||
Строки fallback-путей не менялись.
|
||||
3. **Новых проблем в фрагменте нет**: `import math` не конфликтует (имя `math` в модуле
|
||||
ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783)
|
||||
соответствует поведению.
|
||||
|
||||
Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное):
|
||||
- `GetForumTopicsRequest` имеет `limit=5`, поэтому фактически `len(topics) ≤ 5` — случай
|
||||
«30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи
|
||||
тем вырастет.
|
||||
- Пол «минимум 3» может заметно превышать маленький `limit` (напр. limit=3 при 5 темах →
|
||||
до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочих
|
||||
`sample_size` 3..30 деградации нет.
|
||||
@@ -0,0 +1,24 @@
|
||||
### Task 5: Оценка контента (язык, темы, fit по профилю задачи)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery_eval.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`.
|
||||
- Produces:
|
||||
- `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 симв.), сортировка по количеству сообщений (убыв.).
|
||||
- `async def evaluate_message(task: dict, text: str) -> dict` — `{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`:
|
||||
1) текст пустой/длина <10 → fit False «слишком короткое»;
|
||||
2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»;
|
||||
3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4;
|
||||
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"}]}`.
|
||||
- `def passed(ev: dict, task: dict) -> bool` — `ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`.
|
||||
|
||||
- [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа):
|
||||
`Оцени, относится ли сообщение к сфере/задаче. Описание: {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 по ключу.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task 5 — Отчёт: Оценка контента (язык, темы, fit по профилю задачи)
|
||||
|
||||
## Статус
|
||||
✅ Реализовано и проверено (py_compile + офлайн-сценарий на временной БД в контейнере).
|
||||
|
||||
## Файлы
|
||||
- Создан: `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`.
|
||||
- `group_by_topic(messages)` — группировка по `topic_id` (`None → "main"`); `[{"topic_id", "title", "messages": [...]}]`; title — сниппет первого непустого текста темы (≤60 симв., whitespace схлопнут); группы отсортированы по числу сообщений (убыв.), порядок сообщений внутри группы — входной (хронологический из `discovery_read`).
|
||||
- `evaluate_message(task, text)` async — каскад:
|
||||
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"`; иначе ниже;
|
||||
3. ИИ: если `aiEnabled` **и** доступен ключ/локальный провайдер (см. Concerns) — один `ai_service.chat_json(промпт, user="Сообщение:\n"+text[:4000])`; `{fit, reason}` из ответа; любая ошибка → шаг 4;
|
||||
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`).
|
||||
- `passed(ev, task)` — `ev["total"] >= 3` и `ev["fit_ratio"]*100 >= task["threshold"]`.
|
||||
- Промпт ИИ — константа `_AI_PROMPT` точно по тексту брифа (description/keywords подставляются через `.format`).
|
||||
|
||||
## Вывод проверок
|
||||
1. `cd /c/telbase && python -m py_compile backend/app/services/discovery_eval.py` → `PY_COMPILE OK` (без ошибок).
|
||||
2. Сборка и офлайн-прогон на временной БД:
|
||||
- `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`:
|
||||
- `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), входной порядок внутри группы сохранён;
|
||||
- `evaluate_message` на задаче без ИИ/ML (`aiEnabled=False`, `mlEnabled=False`) → эвристика: `{"fit": True, "reason": "совпал ключ "python"", "source": "heuristic"}`; без ключа → False «нет совпадений с ключами»; «короче» → False «слишком короткое»;
|
||||
- `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.
|
||||
- Временный файл `data/task5_check.py` удалён после прогона.
|
||||
3. Диагностика файла — без ошибок и предупреждений.
|
||||
|
||||
## Concerns
|
||||
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 на эвристику (как и требует бриф).
|
||||
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 для пустых текстов.
|
||||
5. **Лимиты на границе**: тексту в ИИ обрезается до 4000 симв. (как в `ai.filter_incoming`), причина из ИИ — до 200 симв.; эвристика и длина считаются по полному тексту.
|
||||
6. Решения оценки нигде не логируются и не учитываются в счётчиках `ml_client.track_decisions` — модуль чистый; учёт/логирование при необходимости добавить в Task 6 на уровне воркера.
|
||||
@@ -0,0 +1,28 @@
|
||||
### Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery_worker.py`
|
||||
- Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c)
|
||||
|
||||
**Interfaces:**
|
||||
- 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"}`).
|
||||
|
||||
Логика 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 кандидатов». Возврат.
|
||||
2. Иначе взять первого кандидата статуса `new` задачи:
|
||||
- `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.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
|
||||
- Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён».
|
||||
- Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)».
|
||||
3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`:
|
||||
- повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом;
|
||||
- `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`.
|
||||
4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`.
|
||||
|
||||
- [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`.
|
||||
- [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 6 — Отчёт: воркер Discovery (поиск → оценка → авто-вступление)
|
||||
|
||||
## Статус
|
||||
✅ Реализовано и проверено (py_compile + сборка образа + офлайн-прогон на временной БД с моками Telegram/пауз).
|
||||
|
||||
## Файлы
|
||||
- Создан: `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`.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### Структура `tick()` (одно действие за вызов, возврат `{"action": ..., "taskId": ...}`)
|
||||
Приоритеты (как в брифе + пожелание про стоп-кран):
|
||||
0. `ban_guard.global_paused()` → сразу `{"action": "none"}` (стоп-кран останавливает весь tick).
|
||||
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`. Пустые/съехавшие ключи закрываются без сетевого вызова.
|
||||
3. **Шаг оценки**: первый кандидат `status='new'`:
|
||||
- `discovery_info` → `participants`, `kind` (`forum`, если `is_forum`; иначе маппинг RU-kind), обновляются name/username/hue;
|
||||
- `minSubscribers`>0: participants меньше → `delete_candidate` + лог `skip` «мало участников (X < min)»; participants не получены → метка «участники не подтверждены»;
|
||||
- `discovery_read`: `ok=False` → `review` + метка «канал: история недоступна» (channel) или «закрытая группа (история скрыта) — вступите сами» (group/forum); контент не оценивается, для ru-задач добавляется метка «язык не подтверждён»;
|
||||
- язык (только ru-задачи): `False` → `delete_candidate` + skip «язык не русский»; `None` → метка «язык не подтверждён»;
|
||||
- объём: содержательных <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)»;
|
||||
- `bump_counter(evaluated)` при выходе кандидата из `new` (review или delete).
|
||||
4. **Авто-вступление** (отдельный проход, приоритет ниже оценки): задача `autoJoin=True` + кандидат `review` + `ban_guard.can_auto_join()` (иначе `none`):
|
||||
- повторная проверка «мы не состоим» прямым SQL по `dialogs`/`disc_blacklist` (интерфейсы Task 3 не менялись) — если уже вступили/в чёрном списке → `mark_rejected` + лог `reject`, action `reject`;
|
||||
- `await ban_guard.wait_join_delay()` (50–70 с);
|
||||
- `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` для ретрая).
|
||||
|
||||
Возвращаемые action: `search|review|skip|join|reject|flood|error|done|none`. Метки/topics-контракт продублирован в docstring модуля.
|
||||
|
||||
### Контракт меток и тем (в docstring `discovery_worker.py`)
|
||||
- `marks` — список строк: «участники не подтверждены», «язык не подтверждён», «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», «мало сообщений».
|
||||
- `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` (без ошибок).
|
||||
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`):
|
||||
```
|
||||
SEARCH_OK # пустая система → none; поиск: кандидат добавлен, «уже мониторится» пропущен, «поиск завершён: 1 кандидатов»
|
||||
EVAL_REVIEW_OK # оценка: review, fitRatio 0.75, langRu=True
|
||||
EVAL_LANG_SKIP_OK # язык не русский → delete + skip
|
||||
EVAL_FORUM_OK # форум: kind=forum, topics по темам (passed/нет), fitRatio агрегат
|
||||
EVAL_FEW_OK # <3 сообщений → review + «мало сообщений»
|
||||
EVAL_NO_HISTORY_OK # история недоступна → review + «канал: история недоступна» + «язык не подтверждён»
|
||||
AUTOJOIN_DONE_OK # join → joined/autoJoined + dialogs(monitor) + join_auto; план → done + лог done
|
||||
REJECT_RECHECK_OK # «вступили между оценкой и join» → mark_rejected + reject (без join)
|
||||
TASK6_OFFLINE_OK
|
||||
```
|
||||
4. Диагностика `discovery_worker.py` — без ошибок и предупреждений (ruff I/SIM/default — чисто; импорты/`_we_are_in`/`suppress` приведены к правилам).
|
||||
|
||||
## Concerns
|
||||
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 не менялись).
|
||||
3. `minSubscribers`-ветка «участники не подтверждены» помечает кандидата только когда минимум задан (`minSubscribers>0`); при `min=0` отсутствие participants не метка (участники не критерий).
|
||||
4. Стоп-кран `discPaused` (`ban_guard.global_paused()`) останавливает весь tick — включая поиск и оценку, не только авто-join (по требованию задания).
|
||||
5. Running-задача без работы (поиск завершён, кандидатов нет, `autoJoin=False`) остаётся running и даёт `{"action":"none"}` каждые 5 c — завершение/удаление такой задачи за пользователем (по брифу).
|
||||
6. `fitRatio` форума — агрегат по всей выборке (fit из N), а не максимум темы; вердикт форума — «есть ≥1 проходная тема». Формат зафиксирован в docstring и в этом отчёте (UI Task 9 показывает темы с per-topic X из N).
|
||||
@@ -0,0 +1,23 @@
|
||||
### Task 7: API Discovery
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/discovery_routes.py`
|
||||
- Modify: `backend/app/main.py` (регистрация роутера)
|
||||
|
||||
**Interfaces:**
|
||||
- Prefix `/api/discovery`, auth `current_login`:
|
||||
- `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": [...]}` (8–16 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
|
||||
- `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}/reject` — `mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
|
||||
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
|
||||
- `GET /tasks/{id}/log`
|
||||
|
||||
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
|
||||
|
||||
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
|
||||
- [ ] **Step 2: Зарегистрировать в main.py**.
|
||||
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,52 @@
|
||||
# Task 7 — Отчёт: API Discovery
|
||||
|
||||
## Статус
|
||||
Выполнено. Роутер `/api/discovery` реализован, зарегистрирован в `main.py`, проверен на живом контейнере.
|
||||
|
||||
## Файлы
|
||||
- Создан: `backend/app/routers/discovery_routes.py` (prefix `/api/discovery`, tags `discovery`, auth `current_login`).
|
||||
- Изменён: `backend/app/main.py` — импорт `discovery_routes` и добавление в цикл `include_router`.
|
||||
|
||||
## Что сделано
|
||||
### Эндпоинты
|
||||
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`;
|
||||
- `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`;
|
||||
- `POST /tasks/{id}/generate-keywords` — ИИ-генерация ключей по описанию задачи;
|
||||
- `GET /tasks/{id}/candidates?status=new|review|joined|rejected` (необязателен; невалидный статус — 422 через `Literal`);
|
||||
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот/пауз воркера);
|
||||
- `POST /candidates/{dialog_id}/reject` — отклонение с добавлением в чёрный список;
|
||||
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`;
|
||||
- `GET /tasks/{id}/log`.
|
||||
|
||||
### Модели и контракт
|
||||
- `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}`.
|
||||
- Обработка: `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 10–16, 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 с текстом.
|
||||
- `reject`: статус `joined` → 400 «Уже вступили — удалите источник из каналов»; иначе `mark_rejected(reason="отклонено вручную")`.
|
||||
- Роутер чисто проходит `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.
|
||||
2. `docker compose build app` → `Image telbase-app Built`; `docker compose up -d app` → контейнер пересоздан, `/api/health` → 200.
|
||||
3. Живой API (логин admin/admin, куки):
|
||||
- `POST /api/discovery/tasks {"name":"","planJoins":1}` → **400** `{"detail":"Укажите название задачи"}`;
|
||||
- `POST /api/discovery/tasks` корректная (plan=1, ключи пустые) → **200**, задача `status:"draft"`, дефолты `threshold:40/sampleSize:10` подставлены;
|
||||
- `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":[]}`;
|
||||
- `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`;
|
||||
- `POST /api/discovery/tasks/{id}/start` при пустых ключах → **400** `{"detail":"Нет ключевых слов для поиска — добавьте их в задачу"}`;
|
||||
- `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":[]}`;
|
||||
- `start`/`PATCH`/`candidates` по несуществующей задаче → **404** `{"detail":"Задача не найдена"}`;
|
||||
- `POST /api/discovery/candidates/{id}/join` и `/reject` по несуществующему кандидату → **404** `{"detail":"Кандидат не найден"}`;
|
||||
- `GET /api/discovery/tasks/{id}/candidates?status=bogus` → **422** (валидация `Literal`);
|
||||
- `DELETE /api/discovery/blacklist/{id}` (нет записи) → **200** `{"ok":true}`.
|
||||
|
||||
## Concerns
|
||||
1. Ветки `join`/`reject` с реальным кандидатом и реальным `tg.discovery_join` (в т.ч. «уже joined» → 400 и ошибка Telegram → 400) живьём не гонялись — нужен подключённый Telegram-аккаунт и настоящий кандидат; это ручная проверка уровня Task 10. Контрактные 404/422 проверены.
|
||||
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` чист.
|
||||
4. Под Windows/MSYS кириллица в `curl -d '...'` ломает тело запроса («There was an error parsing the body») — проверки с кириллицей делались через `--data @файл` (UTF-8). К продакшену отношения не имеет.
|
||||
@@ -0,0 +1,17 @@
|
||||
### Task 8: Фронтенд — store + каркас подвкладки «Поиск»
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store.js`
|
||||
- Create: `frontend/src/views/DiscoveryView.vue`
|
||||
- Modify: `frontend/src/views/ChannelsView.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- 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)`.
|
||||
|
||||
- [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`).
|
||||
- [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу.
|
||||
- [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
|
||||
- [ ] **Step 4: `npm run build`** — без ошибок.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,46 @@
|
||||
# Task 8 — Отчёт: Фронтенд — store + каркас подвкладки «Поиск»
|
||||
|
||||
## Статус
|
||||
Выполнено. `npm run build` проходит без ошибок.
|
||||
|
||||
## Файлы
|
||||
- Изменён: `frontend/src/store.js` — состояние Discovery, сброс при разлогине, 13 функций.
|
||||
- Изменён: `frontend/src/views/ChannelsView.vue` — сегмент «Каналы | Поиск каналов» и рендер по табу.
|
||||
- Создан: `frontend/src/views/DiscoveryView.vue` — список задач + панель активной задачи/мастер.
|
||||
|
||||
## Что сделано
|
||||
### store.js
|
||||
- Состояние (по интерфейсу брифа): `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)`.
|
||||
- Контракт в 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`).
|
||||
- `saveDiscTask` — создаёт/патчит, кладёт задачу в `discTasks`, ставит `discActiveTaskId`, возвращает задачу или `null`.
|
||||
- `generateDiscKeywords` — **error-ветка API** (`{"keywords": [], "error": "..."}`) → `toast(error)`, возвращает `null`; успех → массив `keywords` (может быть пустым).
|
||||
- `deleteDiscTask` — после удаления активной задачи выбирает первую оставшуюся.
|
||||
- `loadDiscTasks` — сохраняет активную задачу, если она ещё существует, иначе выбирает первую (правая панель не пустует).
|
||||
- `join/rejectDiscCandidate` — на успехе убирают кандидата из текущего списка `discCandidates` + toast; `removeDiscBlacklist` фильтрует по `dialogId`. Поля кандидата/блэклиста не используются в UI до Task 9.
|
||||
|
||||
### ChannelsView.vue
|
||||
- В шапке — сегмент «Каналы | Поиск каналов» (мелкие кнопки в контейнере `bg-ink/60 border border-white/5`, активный — `bg-white/8 text-hi`), переключение через `gotoChannelsTab`. Подзаголовок шапки и правые действия («Перечитать», «Включить все», поиск по имени) показываются только на табе `'list'`.
|
||||
- Синхронизация списка каналов (watch по `state.view` + `onMounted`) ограничена условием `channelsTab === 'list'`; добавлен отдельный watch по `channelsTab` — возврат на таб «Каналы» освежает список.
|
||||
- При `channelsTab === 'search'` вместо списка каналов рендерится `<DiscoveryView/>` (импорт из `./DiscoveryView.vue`).
|
||||
|
||||
### DiscoveryView.vue (каркас: задачи + мастер)
|
||||
- Слева: колонка `w-[300px]` со списком задач (`state.discTasks`; имя + чип статуса + ключи/найдено/вступили), кнопка «Новая задача», refresh. Пустое состояние — с подсказкой.
|
||||
- Справа: панель активной задачи (`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`, чипы как у стоп-фраз/сферы.
|
||||
- Кнопки: «Запустить» (градиент; 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 (статус/счётчики живут на бэке у воркера).
|
||||
- Без заглушек-обещаний: панель кандидатов/лога не рисовалась (ожидаемо — Task 9).
|
||||
- UX-детали: «Запустить» и «Сгенерировать ключи ИИ» при необходимости сначала сохраняют форму (`saveDiscTask`), т.к. старт и генерация идут по id задачи на сервере; черновик «Новой задачи» не затирается ручным refresh списка.
|
||||
|
||||
## Вывод проверок
|
||||
1. `cd /c/telbase/frontend && npm run build` → `✓ built in 1.32s`, `42 modules transformed`, ошибок нет.
|
||||
2. `diagnostics` по `store.js`, `ChannelsView.vue`, `DiscoveryView.vue` — ошибок/предупреждений нет.
|
||||
|
||||
## Concerns
|
||||
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение функций опирается на контракт Task 7 (`{"items": [...]}`, camelCase, 200/400/404). Ручная проверка сценариев — за Task 10.
|
||||
2. Генерация ключей ИИ по API идёт от id задачи (`POST /tasks/{id}/generate-keywords`), поэтому при генерации из нового черновика кнопка сначала создаёт задачу (нужно название — при пустом показывается toast «Укажите название задачи»). После генерации ключи кладутся в форму, в БД фиксируются кнопкой «Сохранить» (или автоматически при «Запустить»).
|
||||
3. Дефолты формы (threshold 40 / sampleSize 10) повторяют дефолты сервиса; при создании бэкенд может подставить значения настроек `discEvalThreshold/discEvalSample` — после «Сохранить» форма перечитывается из ответа сервера (`pickFromTask`), так что расхождение схлопывается.
|
||||
4. `channelsTab` глобальный и переживает уход/возврат на экран «Каналы» (как `settingsTab`); активная задача Discovery тоже сохраняется между визитами, при её удалении/исчезновении выбирается первая.
|
||||
5. Опрос статуса running-задачи — простой `setInterval` 4.5 c на время нахождения на вкладке; при большом числе задач можно позже перевести на точечный `GET /tasks/{id}` или SSE.
|
||||
@@ -0,0 +1,15 @@
|
||||
### Task 9: Фронтенд — кандидаты, действия, чёрный список, история
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/DiscoveryView.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 8 (store).
|
||||
|
||||
- [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0.
|
||||
- [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
|
||||
- [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings).
|
||||
- [ ] **Step 4: История** — лог задачи.
|
||||
- [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 9 — Отчёт: Фронтенд — кандидаты, действия, чёрный список, история
|
||||
|
||||
## Статус
|
||||
Выполнено. `cd /c/telbase/frontend && npm run build` — без ошибок (`✓ built`, 42 modules transformed).
|
||||
`python -m py_compile backend/app/routers/settings_routes.py` — OK.
|
||||
|
||||
## Файлы
|
||||
- Изменён: `frontend/src/views/DiscoveryView.vue` — табы кандидатов/истории/чёрного списка, карточки кандидатов, квоты.
|
||||
- Изменён: `frontend/src/store.js` — `state.discCounts` + `loadDiscCounts(taskId)`; `loadDiscCandidates` обновляет счётчик текущего статуса.
|
||||
- Изменён: `frontend/src/components/Icon.vue` — новые иконки `users`, `megaphone`, `list` (чипы вида источника, участники, темы).
|
||||
- Изменён: `backend/app/routers/settings_routes.py` — `discPaused` добавлен в `_PUBLIC_BOOL` (одной строкой).
|
||||
|
||||
## Что сделано
|
||||
### Табы панели задачи (DiscoveryView, правая колонка)
|
||||
- Под «мастером» и действиями задачи — панель с табами: «В обработке» (`new`), «На рассмотрении» (`review`), «Вступили» (`joined`), «Отклонены» (`rejected`), «История» (лог), «Чёрный список».
|
||||
- Бейджи-счётчики (`state.discCounts`) скрыты при 0; переключение таба вызывает `loadDiscCandidates(taskId, status)` / `loadDiscLog(taskId)` / `loadDiscBlacklist()`; смена активной задачи и повторный клик по ней — авто-загрузка панели (`loadPanel`).
|
||||
- Счётчики всех четырёх статусов обновляет `loadDiscCounts` (4 параллельных GET по статусам). Защита от гонок при быстром переключении табов/задач — `panelSeq` (stale-ответ перезагружает актуальный таб).
|
||||
- Пока задача `running` — список кандидатов/счётчики тихо обновляются каждый второй тик опроса (~9 c).
|
||||
|
||||
### Карточка кандидата
|
||||
- Аватар по `hue` (как в ChannelsView), название, `@username`, чип вида (канал `megaphone`/brand, группа `users`/online, форум `list`/warn), участники (`N участник/а/ов`, «—» при None), метки `marks` чипами (важные — закрытая группа/история недоступна — подсвечиваются warn).
|
||||
- Соответствие: для канала/группы — «подходит N%» (по `fitRatio`); для форума — «подходит тем: N из M» и раскрывающийся список тем «тема „{title}" — подходит {fitCount} из {total}» с passed-подсветкой (pass — online, нет — приглушённый).
|
||||
- Кнопки «Вступить и мониторить» и «Отклонить» — только для `status === 'review'`; join без подтверждения, reject через `askConfirm` (уходит в чёрный список). Для `new` кнопок нет — подпись «воркер оценит источник». После join/reject счётчики перечитываются.
|
||||
- Раскрытие тем форума — локальный `Set` `expanded` (chevron).
|
||||
|
||||
### Чёрный список
|
||||
- Выбран отдельный таб «Чёрный список» в той же панели (читабельно и не спорит с макетом). Строка: иконка, имя, причина/дата, кнопка «Снять» (`removeDiscBlacklist`, guard от двойного клика `unbanId`).
|
||||
|
||||
### Квоты авто-вступлений
|
||||
- Маленькая кнопка «Квоты» в шапке левой панели (рядом со списком задач) → inline-блок: суточный лимит `discJoinLimit`, паузы мин/макс `discJoinDelayMin/Max` (сек), стоп-кран-переключатель `discPaused`.
|
||||
- Дефолты не хардкодятся: при первом открытии блока — `GET /api/settings` (локальная загрузка), сохранение чисел — `PATCH /api/settings` одним объектом (клампы 1–200 и 5–600 как на бэке, min ≤ max), стоп-кран патчится сразу при переключении.
|
||||
- На бэке `discPaused` добавлен в `_PUBLIC_BOOL` — теперь принимается PATCH и отдаётся в GET.
|
||||
|
||||
### store.js
|
||||
- `state.discCounts = { new:0, review:0, joined:0, rejected:0 }`.
|
||||
- `loadDiscCandidates` дополнительно пишет `discCounts[status]`.
|
||||
- `loadDiscCounts(taskId)` — Promise.all по 4 статусам, заполняет `discCounts` (не трогает `discCandidates`).
|
||||
|
||||
## Вывод проверок
|
||||
1. `cd /c/telbase/frontend && npm run build` → `✓ built in 1.24s`, `42 modules transformed`, ошибок нет.
|
||||
2. `diagnostics` по `DiscoveryView.vue`, `store.js`, `Icon.vue` — ошибок/предупреждений нет.
|
||||
3. `python -m py_compile backend/app/routers/settings_routes.py` → OK.
|
||||
|
||||
## Concerns
|
||||
1. Живой API/UI не гонялся (нет запущенного бэкенда/дев-сервера в этой сессии); поведение опирается на контракт Task 7. Визуальная проверка сценариев — за Task 10.
|
||||
2. Кнопка «Квоты» дергает `GET/PATCH /api/settings` напрямую из вью (в store нет state-полей под диск-квоты, по брифу разрешена локальная загрузка/сохранение через `api.patch`). Это единственное место, где вью импортирует `api` напрямую — если захочется строгой «только через store», квоты стоит перевести в store-функции.
|
||||
3. Счётчики табов получаются отдельными запросами по каждому статусу (у бэкенда нет эндпоинта counts); при активном воркере это ~4 лёгких GET каждые ~9 c — приемлемо для текущих объёмов, но при росте числа кандидатов можно добавить серверный `counts`.
|
||||
4. «На рассмотрении» — таб по умолчанию для активной задачи (в `state.discCandidateStatus` изначально `review`); таб «История»/«Чёрный список» сохраняется при переключении задач.
|
||||
5. `progress.md` не трогался.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Ledger: codestyle-residue (2026-09-11, вечер)
|
||||
|
||||
План: `docs/superpowers/plans/2026-09-11-codestyle-остатки.md`
|
||||
|
||||
## Итог
|
||||
|
||||
- Замер: дубли `<summary>` (текст и имя члена) — 0; var-литералы/касты в src — 0; латинских комментариев — 18
|
||||
(из них англоязычных 3); членов интерфейсов без дока — 4; TODO — 0; CRLF-файлов — 1029 из 1445
|
||||
(все 42 .sh — CRLF).
|
||||
- `.editorconfig`: `csharp_style_var_for_built_in_types = false:warning`; `end_of_line = lf`.
|
||||
- `dotnet format style --diagnostics IDE0008 --severity warn` по 5 sln — 51 файл (только встроенные типы).
|
||||
- `.gitattributes` добавлен (`* text=auto eol=lf` + бинарные исключения); 1029 файлов конвертированы в LF;
|
||||
`git add --renormalize .`.
|
||||
- `fix_private_docs.py --apply` — 12 блоков; `<summary>` добавлены: `IContainerRules.Keywords/Stack`,
|
||||
`ITenantContext.TenantId/HasTenant`; переведены 3 комментария (SettingsKeys, OperatorAuthService,
|
||||
ConversionRecomputerTests); `.pyc` из индекса убраны; STATUS.md — устаревший блок удалён.
|
||||
- Явные реализации интерфейсов — владельцу на точечное ревью (не автоматизировано сознательно).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `dotnet build` 5 sln (core, telegram, ai, ml, storage): 0 warnings / 0 errors.
|
||||
- `sh scripts/test.sh`: все 5 тест-проектов зелёные (счётчики — STATUS.md), `lint:i18n` — зелёный.
|
||||
- Фронт содержательно не менялся.
|
||||
@@ -0,0 +1,53 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-scaffold.md
|
||||
|
||||
Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger.
|
||||
Ревью выполняется по фактическим файлам дерева (пакеты diff недоступны без git).
|
||||
|
||||
## Todos
|
||||
- [x] Task 1: Структура src/ и перенос фронтенда
|
||||
- [x] Task 2: Стандарты кода — .editorconfig, Directory.Build.props
|
||||
- [x] Task 3: Решение Deal.sln и пустые проекты core
|
||||
- [x] Task 4: Тесты — xUnit-каркас
|
||||
- [x] Task 5: Dev-Postgres в docker compose (схема на тенанта)
|
||||
- [x] Task 6: Tenant-контекст и подключение к Postgres
|
||||
- [x] Task 7: EF Core + миграции (public)
|
||||
- [x] Task 8: Применение миграций ко всем схемам тенантов
|
||||
- [x] Task 9: CI-скрипты и финальная проверка этапа
|
||||
|
||||
## Pre-flight scan (таблица пар задач и внутренней согласованности)
|
||||
|
||||
| Пара | Производит / потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T1 → T5 | T1 создаёт корневой README.md; T5 его дополняет | Чисто |
|
||||
| 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 |
|
||||
| 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 | счётчики тестов: T4=1 (MarkerTests), +T6=2 → 3 PASS | Чисто (внутренне согласовано в T6 Step 3) |
|
||||
| T6 → T7 | ConnectionStringProvider в Infrastructure зависит от IConfiguration | Внутреннее расхождение: в плане нет пакета конфигурации для Infrastructure — см. Ruling 4 |
|
||||
| T6 → T6 | ITenantContext/AsyncLocal, SqlSchema search_path | Чисто |
|
||||
| T7 → T8 | T8 консьюмит TenantId; отдельно SQL | Чисто |
|
||||
| T7 | dotnet-ef миграция требует tool | Ruling 5 (локальный tool-manifest вместо --global) |
|
||||
| T7 | IDesignTimeDbContextFactory в Infrastructure; API как startup | Чисто |
|
||||
| T2 → T3 | warnings-as-errors на новых шаблонах .NET 10 | Риск: неожиданные NETSDK-предупреждения; при необходимости ослабить severity в .editorconfig (записано в Ruling 1) |
|
||||
| T1 | `cp -r frontend/*` тянет node_modules/dist | Приемлемо (без git всё хранится в дереве); проверить копию ДО rm (Ruling 2) |
|
||||
|
||||
## 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 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 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`). Стоимость при ошибке: лишний файл манифеста.
|
||||
|
||||
## Task status
|
||||
- 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 3: complete (review clean). Отчёт: task-3-report.md.
|
||||
- Task 4: complete (review clean). Отчёт: task-4-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 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 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,0 +1,68 @@
|
||||
# Task 1 — «Структура src/ и перенос фронтенда» — отчёт
|
||||
|
||||
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 1, строки 27–88).
|
||||
|
||||
## Что сделано
|
||||
|
||||
**Step 1. Структура каталогов** — создано:
|
||||
```
|
||||
src/core src/ml-service src/ai-service src/telegram-service src/contracts
|
||||
```
|
||||
(5 пустых каталогов, плюс `src/frontend` создан на шаге 2 — итого 6.)
|
||||
|
||||
**Step 2. Перенос фронтенда** — `frontend/` → `src/frontend/`.
|
||||
- Скопировано содержимое старого `frontend/` включая скрытые файлы (`.gitignore`):
|
||||
`mkdir -p src/frontend && cp -r frontend/. src/frontend/`.
|
||||
- Затем (после верификации копии) старый `frontend/` удалён: `rm -rf frontend`.
|
||||
|
||||
**Step 3.** Создан `src/README.md` — содержимое 1-в-1 из плана (строки 55–66).
|
||||
**Step 4.** Создан корневой `README.md` — содержимое 1-в-1 из плана (строки 71–79).
|
||||
|
||||
**Не создано ничего сверх списка**: ни `Directory.Build.props`, ни csproj, ни иных файлов.
|
||||
LeadRadar-код (`backend/`, `mlservice/`, `docker-compose.yml`, `docs/`) не тронут.
|
||||
|
||||
## Проверки (выводы команд)
|
||||
|
||||
- Счёт файлов старого `frontend` ДО переноса: `find frontend -type f | wc -l` → **1085**.
|
||||
- Счёт файлов `src/frontend` ПОСЛЕ переноса (до удаления старого): → **1085** — равно.
|
||||
- Полное сравнение списков файлов (относительные пути, отсортированы):
|
||||
`diff` списков `frontend/` и `src/frontend/` → **IDENTICAL**.
|
||||
- `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/`,
|
||||
`node_modules/`, `package-lock.json`, `.gitignore`, `nul`, логи vite.
|
||||
- `test -f README.md && echo ok` → `ok`; `test -f src/README.md` → `ok`.
|
||||
- После удаления: `find src/frontend -type f | wc -l` → **1085**; `frontend/` отсутствует.
|
||||
|
||||
## Отклонение от буквы плана (обоснованное)
|
||||
|
||||
1. План: `cp -r frontend/* src/frontend/`. Использовано `cp -r frontend/. src/frontend/`:
|
||||
glob `*` не захватывает dot-файлы (`.gitignore`), что нарушило бы требование равенства
|
||||
счётчиков файлов (guard) — 1085 до vs 1084 после. `frontend/.` копирует и скрытые файлы,
|
||||
результат проверен: списки файлов идентичны.
|
||||
2. План: `cp -r ... && rm -rf frontend` одной командой. Деструктивный шаг выполнен отдельно,
|
||||
только после верификации копии, как требует guard.
|
||||
|
||||
## Инцидент: запущенный vite dev server в старом frontend/
|
||||
|
||||
`rm -rf frontend` частично не удался: файлы `frontend/vite-dev.log`, `frontend/vite-dev.err.log`
|
||||
и пересозданный кэш `frontend/.vite/deps/*` были заблокированы (Device or resource busy).
|
||||
Причина установлена: с 2026-09-02 работал живой процесс vite dev server
|
||||
(`node C:\telbase\frontend\node_modules\.bin\..\vite\bin\vite.js`, Windows PID 395892, родитель cmd 392284),
|
||||
державший дескрипторы на логи в старом каталоге. Его `node_modules/`, `src/`, `dist/` уже были удалены
|
||||
шагом 2, т.е. сервер был неработоспособен независимо от наших действий.
|
||||
Выполнено: `taskkill //PID 395892 //T //F` (только дерево этого vite-процесса), после чего
|
||||
`rm -rf frontend` успешен. Остальные node-процессы не трогались.
|
||||
|
||||
## Опасения / Concerns
|
||||
|
||||
- Был принудительно завершён зависший vite dev server старого `frontend/` (см. инцидент).
|
||||
При необходимости dev-сервер нужно запускать заново из `src/frontend/`.
|
||||
- В `src/frontend/` переехал файл с именем `nul` (215 байт, существовал в исходном `frontend/`
|
||||
до переноса) — перенос выполнен как есть, файл сохранён.
|
||||
- Служебные логи `vite-dev.log`/`vite-dev.err.log` и каталог `dist/`, `node_modules/` перенесены
|
||||
как есть (план: «фронтенд переезжает как есть»).
|
||||
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
|
||||
|
||||
## Статус задачи
|
||||
|
||||
DONE_WITH_CONCERNS (все шаги 1–6 выполнены и проверены; concern — завершённый vite-процесс).
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 2 — «Стандарты кода — .editorconfig, Directory.Build.props» — отчёт
|
||||
|
||||
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 2, строки 90–167).
|
||||
|
||||
## Что сделано
|
||||
|
||||
**Step 1.** Создан корневой `.editorconfig` (`C:\telbase\.editorconfig`) — содержимое 1-в-1
|
||||
из плана (строки 102–139 внутри fence, 38 строк): `root = true`, базовые настройки (`[*]`),
|
||||
`[*.{cs,vb}]`, секция стиля фигурных скобок (Allman), using-директивы, явные модификаторы
|
||||
доступа, отключение квалификации `this.`, правила `var`, секция `[*.cs]` с
|
||||
`dotnet_diagnostic.IDE0290.severity = none`.
|
||||
|
||||
**Step 2.** Создан `src/core/Directory.Build.props` — PropertyGroup из плана (строки 146–154)
|
||||
1-в-1: `TargetFramework net10.0`, `LangVersion latest`, `Nullable enable`,
|
||||
`ImplicitUsings enable`, `TreatWarningsAsErrors true`, `AnalysisLevel latest`,
|
||||
`EnforceCodeStyleInBuild true`.
|
||||
|
||||
**Step 3.** Зафиксирован настоящий отчёт.
|
||||
|
||||
**Не создано ничего сверх списка**: задача ограничена двумя файлами. `src/core/` пуст
|
||||
(кроме нового props), код LeadRadar не тронут. Сборка не выполнялась (запрошено задачей;
|
||||
проектов под `src/core` ещё нет — проверка в Task 3+).
|
||||
|
||||
## Рeшение контроллера (применено)
|
||||
|
||||
`<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" ...>`
|
||||
(план, строки 156–161) **полностью опущен**: SDK .NET 10 (10.0.400) уже включает
|
||||
встроенные analyzers той же версии; явная ссылка не нужна. Остальные элементы props
|
||||
совпадают с планом без изменений.
|
||||
|
||||
## Проверки
|
||||
|
||||
- Оба файла существуют: чтение `.editorconfig` (38 строк) и `Directory.Build.props` (11 строк).
|
||||
- `.editorconfig` сверен построчно с планом (строки 102–139) — идентичен, включая пустые
|
||||
строки и русские комментарии.
|
||||
- `Directory.Build.props` сверен с планом (строки 145–154) — идентичен; ItemGroup
|
||||
отсутствует; наличие `PackageReference` в файле не подтверждается (grep-проверка не нужна —
|
||||
содержимое прочитано целиком).
|
||||
- `ls src/core/` → содержит только `Directory.Build.props`.
|
||||
|
||||
## Опасения / Concerns
|
||||
|
||||
- Нет. Правило `EnforceCodeStyleInBuild` + `TreatWarningsAsErrors` начнёт действовать после
|
||||
создания проектов (Task 3) — тогда же и проверка сборки.
|
||||
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
|
||||
|
||||
## Статус задачи
|
||||
|
||||
DONE (все шаги 1–3 выполнены и проверены; отклонений от плана нет, кроме предписанного
|
||||
контроллером исключения PackageReference).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Task 3 — «Решение Deal.sln и пустые проекты core» — отчёт
|
||||
|
||||
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 3, строки 169–258).
|
||||
Рабочая директория: `C:\telbase\src\core`.
|
||||
|
||||
## Что сделано
|
||||
|
||||
**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.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (все — `classlib`).
|
||||
|
||||
**Step 2.** Все 10 проектов добавлены в решение (`dotnet sln Deal.sln add ...` — 10/10 подтверждено выводом).
|
||||
|
||||
**Step 3.** Удалён сгенерированный `Class1.cs` из всех 9 classlib-проектов. Созданы публичные
|
||||
sealed-маркеры (1 тип = 1 файл, имя файла = имени типа, XML-doc, содержимое по плану):
|
||||
`PipelineModuleMarker`, `KanbanModuleMarker`, `ProjectsModuleMarker`, `DiscoveryModuleMarker`,
|
||||
`SettingsModuleMarker`, `TenantsModuleMarker`, `InfrastructureMarker`, `SharedKernelMarker`,
|
||||
`ContractsMarker`.
|
||||
|
||||
**Step 4.** Project references (минимальные, по плану): `Deal.Api` → SharedKernel/Contracts/Infrastructure;
|
||||
каждый модуль и `Deal.Infrastructure` → SharedKernel/Contracts. `Deal.SharedKernel`/`Deal.Contracts` — без ссылок.
|
||||
|
||||
**Step 5.** `Deal.Api/Program.cs` заменён 1-в-1 на содержимое плана (строки 233–245): health-endpoint
|
||||
`GET /api/health` + `public partial class Program`.
|
||||
|
||||
**Step 6.** `dotnet build Deal.sln` → успешно, 0 предупреждений, 0 ошибок (строгий режим
|
||||
TreatWarningsAsErrors + EnforceCodeStyleInBuild соблюдён).
|
||||
|
||||
**Step 7.** Health-проверка: приложение поднято в фоне на `http://localhost:5080`, `curl`
|
||||
вернул ожидаемый JSON, процесс остановлен, порт освобождён (netstat: слушателей нет).
|
||||
|
||||
**Step 8.** Зафиксирован настоящий отчёт.
|
||||
|
||||
## Созданные файлы
|
||||
|
||||
- `src/core/Deal.sln`
|
||||
- `src/core/Deal.Api/Deal.Api.csproj`, `Deal.Api/Program.cs`, `Deal.Api/Properties/launchSettings.json` (шаблон)
|
||||
- `src/core/Deal.Api/Program.cs` — health-версия по плану
|
||||
- 9 classlib-проектов: `Deal.Modules.Pipeline/…Tenants`, `Deal.SharedKernel`, `Deal.Infrastructure`, `Deal.Contracts` (csproj + маркер)
|
||||
- Маркеры: `PipelineModuleMarker.cs`, `KanbanModuleMarker.cs`, `ProjectsModuleMarker.cs`, `DiscoveryModuleMarker.cs`,
|
||||
`SettingsModuleMarker.cs`, `TenantsModuleMarker.cs`, `InfrastructureMarker.cs`, `SharedKernelMarker.cs`, `ContractsMarker.cs`
|
||||
- `ProjectReference` во всех csproj по плану (см. Step 4)
|
||||
- `.superpowers/sdd/deal-scaffold/task-3-report.md`
|
||||
|
||||
## Проверки
|
||||
|
||||
**Сборка** (`dotnet build Deal.sln`, Debug): 10/10 проектов `успешно`, «Сборка успешно выполнено через 7,4 с»;
|
||||
вывод без предупреждений и ошибок (при TreatWarningsAsErrors любое предупреждение уронило бы сборку).
|
||||
|
||||
**Health** (Step 7):
|
||||
|
||||
```
|
||||
=== GET http://localhost:5080/api/health ===
|
||||
{"ok":true,"service":"deal"} ← curl exit 0, ожидаемый ответ
|
||||
=== app log ===
|
||||
Now listening on: http://localhost:5080
|
||||
Hosting environment: Development
|
||||
=== stopping app: kill <pid> ===
|
||||
=== no leftover listener on 5080 === ← процесс остановлен, порт свободен
|
||||
```
|
||||
|
||||
## Опасения / Concerns
|
||||
|
||||
- `.NET 10 default slnx`: `dotnet new sln` в SDK 10.0.400 создаёт `Deal.slnx`, а не `Deal.sln`.
|
||||
План требует именно `Deal.sln` (имя используется в командах `sln add`/`build`). Решение:
|
||||
`Deal.slnx` удалён, решение пересоздано как классическое: `dotnet new sln -n Deal --format sln`.
|
||||
Итог — `Deal.sln` (классический формат), артефактов slnx не осталось.
|
||||
- Health-скрипт: в финальной строке проверки «после остановки» код возврата `curl` выведен неверно
|
||||
(`$?` после `echo`), но факт остановки подтверждён авторитетно: тело ответа пустое (сервер не отвечает)
|
||||
и `netstat` не показывает слушателей на порту 5080.
|
||||
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
|
||||
|
||||
## Статус задачи
|
||||
|
||||
DONE (все шаги 1–8 выполнены; сборка 0/0; health-ответ совпал с ожидаемым; процесс остановлен).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Task 4 — «Тесты — xUnit-каркас» — отчёт
|
||||
|
||||
Дата: 2026-09-05. План: `docs/superpowers/plans/2026-09-05-deal-scaffold.md` (Task 4, строки 261–306).
|
||||
Рабочая директория: `C:\telbase\src\core`.
|
||||
|
||||
## Что сделано
|
||||
|
||||
**Step 1.** Создан тестовый проект:
|
||||
|
||||
```
|
||||
dotnet new xunit -n Deal.Tests.Unit -o 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
|
||||
```
|
||||
|
||||
- Шаблон .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),
|
||||
поэтому `dotnet test` работает штатно, адаптация не потребовалась.
|
||||
- Проект добавлен в `Deal.sln` (11-й проект).
|
||||
- Добавлены 7 ProjectReference строго по плану: `Deal.SharedKernel` + 6 модулей
|
||||
(`Pipeline`, `Kanban`, `Projects`, `Discovery`, `Settings`, `Tenants`). `Deal.Infrastructure` НЕ добавлен
|
||||
(добавляется более поздней задачей).
|
||||
|
||||
**Step 2.** Удалён шаблонный `UnitTest1.cs`. Создан единственный тестовый файл
|
||||
`tests/Deal.Tests.Unit/MarkerTests.cs` — содержимое 1-в-1 из плана (строки 284–298):
|
||||
`MarkerTests` (public sealed) с единственным `[Fact] PipelineModuleMarker_IsPublicAndSealed`,
|
||||
проверяющим `IsPublic`/`IsSealed` у `Deal.Modules.Pipeline.PipelineModuleMarker`.
|
||||
|
||||
**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.08] Discovering: 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.18] Finished: Deal.Tests.Unit
|
||||
Сводка теста: всего: 1; сбой: 0; успешно: 1; пропущено: 0; длительность: 1,4 с
|
||||
Сборка успешно выполнено через 4,5 с
|
||||
```
|
||||
|
||||
## Проверки
|
||||
|
||||
- `dotnet test tests/Deal.Tests.Unit`: 1 тест найден → 1 PASS, 0 fail, 0 skipped.
|
||||
- Сборка 0 предупреждений / 0 ошибок при TreatWarningsAsErrors=true + EnforceCodeStyleInBuild=true —
|
||||
иначе запуск упал бы на любом предупреждении. `Directory.Build.props`/`.editorconfig` не менялись.
|
||||
- В транзитивную сборку попали все 7 referenced-проектов + `Deal.Contracts` (через SharedKernel/модули) — успешно.
|
||||
|
||||
## Созданные файлы
|
||||
|
||||
- `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` (шаблон, без правок)
|
||||
- `src/core/tests/Deal.Tests.Unit/MarkerTests.cs` (по плану, verbatim)
|
||||
- `tests/Deal.Tests.Unit` добавлен в `src/core/Deal.sln`
|
||||
- `.superpowers/sdd/deal-scaffold/task-4-report.md`
|
||||
|
||||
## Опасения / Concerns
|
||||
|
||||
- Шаблонный `Deal.Tests.Unit.csproj` дублирует `Nullable`/`ImplicitUsings`/`TargetFramework`, уже заданные в
|
||||
`src/core/Directory.Build.props` (net10.0). Это безопасно и не вызвало ошибок сборки.
|
||||
- `xunit.runner.visualstudio` 3.1.4 в связке с `xunit` 2.9.3 — штатная комбинация шаблона .NET 10 SDK,
|
||||
тесты обнаружены и выполнены корректно.
|
||||
- Проект не git-репозиторий: коммитов нет, изменения зафиксированы настоящим отчётом.
|
||||
|
||||
## Статус задачи
|
||||
|
||||
DONE (Steps 1–4 плана выполнены; `dotnet test` → ровно 1 PASS; 0 предупреждений / 0 ошибок).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Task 5 Report: Dev-Postgres в docker compose (схема на тенанта)
|
||||
|
||||
Date: 2026-09-05
|
||||
|
||||
## 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/.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.
|
||||
|
||||
## Container status
|
||||
|
||||
- Command: `docker compose -f deploy/compose.dev.yml up -d`
|
||||
- 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`.
|
||||
- Existing LeadRadar containers (`leadradar`, `leadradar-ml`, `leadradar-minio`) were NOT touched and remain running.
|
||||
|
||||
## psql verification (Step 4)
|
||||
|
||||
Command:
|
||||
|
||||
```
|
||||
docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
current_database | current_schema
|
||||
------------------+----------------
|
||||
deal | public
|
||||
(1 row)
|
||||
```
|
||||
|
||||
Expected `deal | public` — PASS. DB `deal`, default schema `public` ready for migrations.
|
||||
|
||||
## README change (Step 5)
|
||||
|
||||
Appended to root `README.md` (existing content intact):
|
||||
|
||||
```markdown
|
||||
## Запуск dev-окружения
|
||||
|
||||
Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d`
|
||||
```
|
||||
|
||||
## Concerns
|
||||
|
||||
- None blocking. Minor notes:
|
||||
- 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`.
|
||||
- `postgres:16-alpine` was already cached locally, so startup was fast; first pull on a fresh host will take longer.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task 6 — Tenant-контекст и подключение к Postgres (отчёт)
|
||||
|
||||
**Дата:** 2026-09-05
|
||||
**Статус:** ✅ Выполнено (build 0 warnings / 0 errors, 3 теста PASS)
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы (по коду плана, verbatim)
|
||||
- `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.Infrastructure/Data/TenantContext.cs` — `AsyncLocal<TenantId?>`, имплементирует `ITenantContext` + `SetTenant`.
|
||||
- `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`.
|
||||
|
||||
### Изменены
|
||||
- `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).
|
||||
|
||||
## Отклонение от кода плана (требование стиля репозитория)
|
||||
|
||||
`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 — перепроектирование не потребовалось.
|
||||
|
||||
## Build
|
||||
|
||||
Команда: `dotnet build Deal.sln` (из `src/core`)
|
||||
|
||||
```
|
||||
Восстановление завершено (0,8 с)
|
||||
Deal.SharedKernel net10.0 успешно выполнено
|
||||
...
|
||||
Deal.Api net10.0 успешно выполнено (0,6 с)
|
||||
Сборка успешно выполнено через 2,5 с
|
||||
```
|
||||
|
||||
Результат: 0 warnings, 0 errors (11 проектов собраны).
|
||||
|
||||
## Tests
|
||||
|
||||
Команда: `dotnet test tests/Deal.Tests.Unit --no-build`
|
||||
|
||||
```
|
||||
Сводка теста: всего: 3; сбой: 0; успешно: 3; пропущено: 0; длительность: 1,1 с
|
||||
```
|
||||
|
||||
PASS: `MarkerTests` (1) + `TenantIdTests` (2) = ровно 3.
|
||||
|
||||
## Concerns / заметки
|
||||
|
||||
1. **Template-level conflict (план vs .editorconfig):** код `ITenantContext` в плане не проходит `IDE0040` — потребовался явный `public` на членах интерфейса. Аналогичное стоит ожидать в будущих задачах, где план опускает модификаторы доступа у членов интерфейса.
|
||||
2. `ConnectionStringProvider` зарегистрирован в DI, но нигде не резолвится (как и задумано для Task 6) — конструктор с `InvalidOperationException` при отсутствии `ConnectionStrings:DealPostgres` сработает только с Task 7 (appsettings).
|
||||
3. NuGet-пакет выбран как `10.0.11` (latest stable, совместим с net10.0). Версия не зафиксирована в плане; при централизованном управлении пакетами (CPM) отсутствует — правок не требуется.
|
||||
4. Загруженных файлов-маркеров (`SharedKernelMarker.cs`, `InfrastructureMarker.cs`) не касались.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Task 7 — EF Core + миграции (public) (отчёт)
|
||||
|
||||
**Дата:** 2026-09-05
|
||||
**Статус:** ✅ Выполнено (build 0 warnings / 0 errors; 4 теста PASS; миграция `InitialPublic` применена к `public`)
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы (по коду плана, verbatim)
|
||||
- `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/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`.
|
||||
- `tests/Deal.Tests.Unit/TenantEntityTests.cs` — тест дефолтов (см. Concern 3: строка `Assert.NotEqual` из плана скорректирована).
|
||||
|
||||
### Изменены
|
||||
- `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/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, а не замена).
|
||||
- `tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — добавлен ProjectReference на `Deal.Infrastructure` (Ruling 3, командой `dotnet add`).
|
||||
|
||||
## Пакеты NuGet (версии, resolved latest stable для net10.0)
|
||||
|
||||
Deal.Infrastructure:
|
||||
- `Microsoft.EntityFrameworkCore` **10.0.11**
|
||||
- `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all, добавлен `dotnet add` автоматически)
|
||||
- `Microsoft.EntityFrameworkCore.Relational` **10.0.11** (см. Concern 1 — добавлен сверх списка плана)
|
||||
- `Npgsql.EntityFrameworkCore.PostgreSQL` **10.0.3**
|
||||
- (ранее, Task 6) `Microsoft.Extensions.Configuration.Abstractions` 10.0.11
|
||||
|
||||
Deal.Api:
|
||||
- `Microsoft.EntityFrameworkCore.Design` **10.0.11** (PrivateAssets=all; см. Concern 2)
|
||||
|
||||
## dotnet-ef: локальный tool
|
||||
|
||||
Глобально 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 tool install dotnet-ef` → **dotnet-ef 10.0.11**, запись добавлена в `src/core/dotnet-tools.json` (`isRoot: true`). Никакой манифест «выше» не найден — `dotnet new tool-manifest` сообщений о существующем не выдавал.
|
||||
|
||||
## Миграция и БД
|
||||
|
||||
Команды (из `src/core`, локальный tool):
|
||||
```
|
||||
dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api
|
||||
Build started... Build succeeded. Done.
|
||||
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api
|
||||
Applying migration '20260905190044_InitialPublic'. Done.
|
||||
```
|
||||
|
||||
Проверка (docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"):
|
||||
```
|
||||
Schema | Name | Type | Owner
|
||||
--------+-----------------------+-------+-------
|
||||
public | __EFMigrationsHistory | table | deal
|
||||
public | tenants | table | deal
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
Дизайн-тайм фабрика использовала fallback-строку (env `DEAL_PG_CONNECTION` не задан).
|
||||
|
||||
## Build
|
||||
|
||||
Команда: `dotnet build Deal.sln` (из `src/core`)
|
||||
|
||||
```
|
||||
Deal.SharedKernel net10.0 успешно выполнено
|
||||
...
|
||||
Deal.Api net10.0 успешно выполнено (0,4 с)
|
||||
Сборка успешно выполнено через 2,3 с
|
||||
```
|
||||
|
||||
Результат: **0 warnings, 0 errors** (11 проектов).
|
||||
|
||||
## Tests
|
||||
|
||||
Команда: `dotnet test tests/Deal.Tests.Unit`
|
||||
|
||||
```
|
||||
Сводка теста: всего: 4; сбой: 0; успешно: 4; пропущено: 0; длительность: 1,0 с
|
||||
```
|
||||
|
||||
PASS: `MarkerTests` (1) + `TenantIdTests` (2) + `TenantEntityTests` (1) = ровно 4.
|
||||
|
||||
## 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» нарушалось.
|
||||
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-строке недостижимо.
|
||||
4. `appsettings.Development.json` уже существовал (Logging-блок); `ConnectionStrings` добавлены merge-правкой — стандартный шаблонный блок сохранён.
|
||||
5. `.editorconfig`/`Directory.Build.props` не редактировались. Генерённые миграционные файлы (`.Designer.cs`, Snapshot) предупреждений при сборке не дают.
|
||||
6. Проект не является git-репозиторием: коммиты/ветки не создавались (как и во всех предыдущих задачах).
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task 8 Report: Применение миграций ко всем схемам тенантов
|
||||
|
||||
**Status:** DONE (with one documented deviation in the test file — see Concerns)
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs` | Created (verbatim, plan lines 736–751) |
|
||||
| `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.
|
||||
`CreateSchemaSql` uses doubled-quote identifier escaping (`Replace("\"", "\"\"")`); `ListTenantSchemasSql` is verbatim. Production code NOT modified relative to the plan.
|
||||
|
||||
## Build output
|
||||
|
||||
`dotnet build Deal.sln` — succeeded: 11/11 projects, **0 warnings / 0 errors**.
|
||||
|
||||
## Test output
|
||||
|
||||
`dotnet test tests/Deal.Tests.Unit --no-build` — **6 PASS / 0 FAIL** (4 existing + 2 new `TenantSchemaMigratorTests`).
|
||||
|
||||
## Concerns / Deviation
|
||||
|
||||
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`.
|
||||
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 `"`).
|
||||
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")
|
||||
was therefore unreachable with that literal assertion.
|
||||
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.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.
|
||||
3. First test (`CreateSchemaSql_IsEscaped`) kept verbatim and passes.
|
||||
4. 1 public type = 1 file respected. `Directory.Build.props` untouched. No git operations performed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Build: `dotnet build Deal.sln` → «Сборка успешно выполнено», 0 warnings/errors.
|
||||
- Tests: `dotnet test tests/Deal.Tests.Unit --no-build` → «всего: 6; сбой: 0; успешно: 6».
|
||||
@@ -0,0 +1,53 @@
|
||||
# Task 9 Report: CI-скрипты и финальная проверка этапа
|
||||
|
||||
**Status:** DONE — scripts created verbatim, both pass, all four final checks green. No deviations.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `scripts/build.sh` | Created (verbatim, plan lines 774–778) |
|
||||
| `scripts/test.sh` | Created (verbatim, plan lines 782–787) |
|
||||
| `.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")/..`):
|
||||
|
||||
- `scripts/build.sh` → `cd src/core && dotnet build Deal.sln`
|
||||
- `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).
|
||||
|
||||
## 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/test.sh` — succeeded: «Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0; длительность: 1,0 с» (build 2,9 с).
|
||||
|
||||
## Final stage verification (Step 4)
|
||||
|
||||
| # | Check | Result |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Check 4 detail (background pattern from earlier tasks):
|
||||
`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"}` →
|
||||
`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).
|
||||
App log tail confirms clean startup («Application started. Press Ctrl+C to shut down.»), no errors.
|
||||
|
||||
## Concerns
|
||||
|
||||
- None blocking. Minor notes:
|
||||
- `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.
|
||||
- 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.
|
||||
- `dotnet run` log redirected to `/tmp/deal-api-task9.log` during the health check for diagnosability.
|
||||
|
||||
## Verification
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md
|
||||
|
||||
Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger.
|
||||
Ревью — по фактическим файлам дерева (diff-пакетов нет).
|
||||
|
||||
## Todos
|
||||
- [x] Task 1: Карта API (выполнена до плана — `docs/api/api-map.md`)
|
||||
- [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.
|
||||
- [x] Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации
|
||||
- Task 3: complete (review clean; 25 PASS). Minor: (1) константы сессии/длины пароля приватны в AuthService — в Task 4 согласовать снаружи (IOptions) для cookie; (2) нормализация login lowercase — осознанно строже прототипа.
|
||||
- [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; build 0/0, тесты 25 PASS, curl-приёмка :5080 — два прогона).
|
||||
Minor: (1) временная DI-заглушка PendingTenantProvisioner до Task 5 (on-build валидация TenantService);
|
||||
(2) seed создаёт пользователя через DealDbContext — в IAuthStore нет CreateUser; (3) Cookies-секция
|
||||
добавлена и в базовый appsettings.json (иначе Days=0 вне Development). Отчёт: task-4-report.md.
|
||||
- [x] Task 5: Провижининг схем тенантов и bootstrap при старте
|
||||
- Task 5: complete (review clean; build 0/0, тесты 25 PASS, приёмка :5080 — два старта).
|
||||
Minor: (1) psql-колонки PascalCase (EF default) — буквальные lowercase-запросы из задания падают, см. отчёт;
|
||||
(2) запуск apphost Deal.Api.exe напрямую вместо `dotnet run` (детерминированная остановка);
|
||||
(3) «30» — единый источник AuthService.SessionLifetimeDays; Cookies:Days убран из appsettings.
|
||||
Отчёт: task-5-report.md.
|
||||
- [x] Task 6: Финал этапа
|
||||
- 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` — при подключении реального фронта сверить.
|
||||
|
||||
## Pre-flight scan
|
||||
|
||||
| Пара | Производит / потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T2 → T3 | T2 создаёт EF-сущности users/sessions/tenants/settings; T3-реализации (AuthStore) их читают | Чисто (реализации в Infrastructure видят Entities) |
|
||||
| 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 формализует провижининг схем. |
|
||||
| T2 → T5 | T5 применяет tenant-миграции из T2 | Чисто |
|
||||
| T2 → T2 | пересоздание dev-БД (Ruling 4) | Dev-данных нет — безопасно |
|
||||
| T3 | модуль не ссылается на Infrastructure | Проверить в ревью (циклов быть не должно) |
|
||||
| T5 | `MigrationsHistoryTable("__TenantMigrationsHistory", schema)` | Подтвердить в ревью фактическим применением |
|
||||
| T6 → T2..T5 | финальные проверки | Чисто |
|
||||
|
||||
## Task status
|
||||
|
||||
- Task 6: complete (review pending). Отчёт: task-6-report.md.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Task 2 — Персистентность: системный и tenant-контексты, миграции. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
|
||||
|
||||
## Итог
|
||||
|
||||
Статус: **DONE_WITH_CONCERNS** (см. «Отклонения»). Сборка 0 warnings/0 errors, тесты 6 PASS,
|
||||
dev-БД пересоздана (public: tenants, users, sessions, `__EFMigrationsHistory` c одной строкой
|
||||
`InitialSystem`), tenant-миграция `InitialTenant` создана, SQL без схемы, не применялась
|
||||
(применение — Task 5).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs` — POCO пользователя
|
||||
(Id = `Guid.NewGuid()` на клиенте, Login, TenantId, PasswordHash, Status = "active", CreatedAt).
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs` — POCO сессии
|
||||
(TokenHash — PK, UserId, Login-денормализация, ExpiresAt, CreatedAt).
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs` — POCO настройки
|
||||
тенанта (Key — PK, ValueJson, UpdatedAt). 1 тип = 1 файл.
|
||||
- `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs` — `ToTable("users","public")`,
|
||||
PK Id, уникальный индекс Login, индекс TenantId, `PasswordHash` text, `Status` default "active",
|
||||
`CreatedAt` default `now()` (SQL), FK `users.TenantId → tenants.Id` ON DELETE RESTRICT.
|
||||
- `src/core/Deal.Infrastructure/Persistence/SessionConfiguration.cs` — `ToTable("sessions","public")`,
|
||||
PK TokenHash (varchar(64)), индексы UserId и ExpiresAt, MaxLength/IsRequired,
|
||||
FK `sessions.UserId → users.Id` ON DELETE CASCADE.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantSettingConfiguration.cs` — `ToTable("settings")`
|
||||
БЕЗ схемы (модель бессхемная, живёт через search_path), PK Key (varchar(200)),
|
||||
`ValueJson` text NOT NULL, `UpdatedAt` required.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantConfiguration.cs` — вынес конфигурацию
|
||||
`TenantEntity` из `DealDbContext.OnModelCreating` для единообразия; маппинг НЕ изменён
|
||||
(ToTable "tenants","public", PK Id, Name varchar(200) NOT NULL) — в рамках опции задачи «по желанию».
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — бессхемный DbContext тенанта;
|
||||
DbSet `Settings`; применяет только `TenantSettingConfiguration`.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs` — design-time фабрика
|
||||
для dotnet-ef; та же строка подключения (env `DEAL_PG_CONNECTION` или localhost:5433);
|
||||
`.UseNpgsql(cs, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))` (без схемы).
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs` — добавлены DbSet `Users`, `Sessions`;
|
||||
`OnModelCreating` применяет `TenantConfiguration/UserConfiguration/SessionConfiguration`
|
||||
(по одной на конфигурацию, без assembly-скана — чтобы не затащить `settings` в системную модель).
|
||||
|
||||
### Удалены
|
||||
- `Migrations/20260905190044_InitialPublic.cs`, `...Designer.cs`, `Migrations/DealDbContextModelSnapshot.cs`
|
||||
(пересозданы начисто по Ruling 4).
|
||||
|
||||
### Не менялись
|
||||
- `TenantEntity.cs`, `Data/TenantContext.cs`, `Data/ConnectionStringProvider.cs`,
|
||||
`Migrations/TenantSchemaMigrator.cs`, `Deal.Api/Program.cs` (регистрации как были;
|
||||
`TenantDbContext` в DI НЕ регистрировался — появится в Task 5), конфиги/тесты.
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
Строка подключения: `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password`
|
||||
(env `DEAL_PG_CONNECTION` не задан — используется fallback фабрик).
|
||||
|
||||
### 1. Сброс схемы dev-БД (Ruling 4)
|
||||
```
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
NOTICE: drop cascades to 2 other objects
|
||||
DETAIL: drop cascades to table "__EFMigrationsHistory"
|
||||
drop cascades to table tenants
|
||||
DROP SCHEMA
|
||||
CREATE SCHEMA
|
||||
```
|
||||
|
||||
### 2. Удаление старых артефактов миграций
|
||||
Удалены `20260905190044_InitialPublic.cs`, `20260905190044_InitialPublic.Designer.cs`,
|
||||
`DealDbContextModelSnapshot.cs` из `Deal.Infrastructure/Migrations/`.
|
||||
|
||||
### 3. Создание миграций (из `src/core`)
|
||||
```
|
||||
$ dotnet ef migrations add InitialSystem --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Done. To undo this action, use 'ef migrations remove'
|
||||
|
||||
$ dotnet ef migrations add InitialTenant --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Done. To undo this action, use 'ef migrations remove'
|
||||
```
|
||||
|
||||
Появились:
|
||||
- `Migrations/20260905192825_InitialSystem.cs` (+ `.Designer.cs`) — tenants+users+sessions в `public`;
|
||||
- `Migrations/DealDbContextModelSnapshot.cs`;
|
||||
- `Migrations/TenantDb/20260905193010_InitialTenant.cs` (+ `.Designer.cs`) — `settings`;
|
||||
- `Migrations/TenantDb/TenantDbContextModelSnapshot.cs`.
|
||||
(см. «Отклонения» — папка `TenantDb`, а не корень `Migrations/`.)
|
||||
|
||||
### 4. Применение системной миграции
|
||||
```
|
||||
$ dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Failed executing DbCommand (19ms) ... SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory" ...
|
||||
Acquiring an exclusive lock for migration application. ...
|
||||
Applying migration '20260905192825_InitialSystem'.
|
||||
Done.
|
||||
```
|
||||
«Failed executing DbCommand» — ожидаемое штатное зондирование отсутствующей (после DROP SCHEMA)
|
||||
таблицы истории перед применением; миграция применена успешно.
|
||||
|
||||
### 5. Проверка `InitialTenant`
|
||||
В файле миграции (`Migrations/TenantDb/20260905193010_InitialTenant.cs`) — только
|
||||
`CreateTable(name: "settings", ...)`, БЕЗ параметра `schema`; grep по `schema|public|tenant_`
|
||||
в трёх файлах tenant-миграции даёт 0 совпадений в SQL (единственное совпадение — ключевое слово
|
||||
C# `public` в `public partial class`). История миграций НЕ создаётся телом миграции —
|
||||
EF создаёт `__TenantMigrationsHistory` при применении (`Migrate`), что соответствует Ruling 3
|
||||
(в Task 5 таблица истории создастся в схеме тенанта). Миграция не применялась.
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
### Build
|
||||
```
|
||||
$ dotnet build Deal.sln --nologo
|
||||
Сборка успешно выполнено через 7,0 с # и повторно: 1,9 с
|
||||
```
|
||||
0 warnings / 0 errors (TreatWarningsAsErrors, AnalysisLevel latest).
|
||||
|
||||
### Tests
|
||||
```
|
||||
$ dotnet test tests/Deal.Tests.Unit --nologo
|
||||
Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0
|
||||
```
|
||||
|
||||
### `dotnet ef migrations list` (из `src/core`)
|
||||
Без `--context` команда завершается ошибкой:
|
||||
```
|
||||
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.
|
||||
```
|
||||
По контекстам:
|
||||
```
|
||||
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
20260905192825_InitialSystem
|
||||
|
||||
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
|
||||
Failed executing DbCommand ... SELECT "MigrationId", "ProductVersion" FROM "__TenantMigrationsHistory" ...
|
||||
20260905193010_InitialTenant (Pending)
|
||||
```
|
||||
`InitialSystem` — применена (без пометки Pending), `InitialTenant` — Pending
|
||||
(таблицы истории в БД нет — это норма: она создаётся при применении в Task 5).
|
||||
|
||||
### psql
|
||||
```
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+-----------------------+-------+-------
|
||||
public | __EFMigrationsHistory | table | deal
|
||||
public | sessions | table | deal
|
||||
public | tenants | table | deal
|
||||
public | users | table | deal
|
||||
(4 rows)
|
||||
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "SELECT \"MigrationId\" FROM public.\"__EFMigrationsHistory\";"
|
||||
20260905192825_InitialSystem # ровно одна строка
|
||||
|
||||
$ \d public.users
|
||||
Id | uuid | not null
|
||||
Login | character varying(200) | not null
|
||||
TenantId | uuid | not null
|
||||
PasswordHash | text | not null
|
||||
Status | text | not null | 'active'::text
|
||||
CreatedAt | timestamp with time zone | not null | now()
|
||||
Indexes: PK_users (Id); IX_users_Login UNIQUE (Login); IX_users_TenantId (TenantId)
|
||||
FK: FK_users_tenants_TenantId → tenants(Id) ON DELETE RESTRICT
|
||||
(Referenced by) FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
|
||||
|
||||
$ \d public.sessions
|
||||
TokenHash | character varying(64) | not null
|
||||
UserId | uuid | not null
|
||||
Login | character varying(200) | not null
|
||||
ExpiresAt | 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)
|
||||
FK: FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
|
||||
```
|
||||
Колонки/индексы соответствуют конфигурациям. `public.tenants` — без изменений по сравнению со
|
||||
скэффолдом (та же схема, что была у `InitialPublic`).
|
||||
|
||||
### Стиль
|
||||
1 тип = 1 файл; XML-doc на public-типах (и на неочевидных свойствах: Login у сессии,
|
||||
TokenHash и т.п.); комментарии на русском; явные модификаторы; регионов нет; именованные
|
||||
константы вместо «магических» длин (`LoginMaxLength = 200`, `TokenHashMaxLength = 64`,
|
||||
`KeyMaxLength = 200`, `NameMaxLength = 200`).
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Расположение tenant-миграции (основное).** `dotnet ef migrations add InitialTenant`
|
||||
(без `--output-dir`, как требует план) НЕ положил файлы в общую папку `Migrations/`,
|
||||
а молча создал подпапку `Migrations/TenantDb/` с namespace `Deal.Infrastructure.Migrations.TenantDb`.
|
||||
Поведение детерминированное (проверено: удалил папку и повторил команду — результат тот же,
|
||||
новый timestamp `20260905193010`). Ошибки или запроса `--output-dir` не было; имена классов
|
||||
(`InitialTenant`, `TenantDbContextModelSnapshot`) действительно не конфликтуют, но инструмент
|
||||
всё равно изолирует второй контекст в подпапку. Функционально ни на что не влияет: EF выбирает
|
||||
миграции контекста по атрибуту `[DbContext(...)]` в assembly, а не по namespace; `Database.Migrate()`
|
||||
в Task 5 найдёт `InitialTenant` и создаст таблицу истории в схеме тенанта. По инструкции задачи
|
||||
(«не придумывайте обходы сами») файлы НЕ переносил и namespace вручную не правил. Если ревьюеру
|
||||
критично именно расположение в корне `Migrations/` — можно пересоздать через
|
||||
`--output-dir Migrations`, но я осознанно оставил детерминированный вывод инструмента.
|
||||
2. **Ожидание `CREATE TABLE "__TenantMigrationsHistory"` в теле миграции.** План (проверка 5)
|
||||
предполагал в `InitialTenant` два `CreateTable`: `settings` и историю. По факту тело миграции
|
||||
содержит только `CREATE TABLE "settings"` — таблица истории миграций в EF создаётся
|
||||
инфраструктурой при применении (`Migrate`/`database update`), а не телом миграции.
|
||||
Требуемое «нет упоминаний схемы» выполнено (0 совпадений). Применение tenant-миграции
|
||||
не выполнялось (по плану это Task 5), поэтому фактическое создание `__TenantMigrationsHistory`
|
||||
в схеме тенанта будет проверено в Task 5 (см. `progress.md`, строка T2→T5).
|
||||
3. **`migrations list` без `--context`.** После появления второго DbContext команда без `--context`
|
||||
падает с «More than one DbContext was found...». Обе миграции видны при запуске по контекстам
|
||||
(см. выше) — это и есть содержимое Acceptance 6; в отчёте зафиксирован требуемый флаг.
|
||||
4. **Колонки/типы `settings`.** Задача (код-уровень) задаёт `Key` с MaxLength 200 и `ValueJson` как
|
||||
`text`; Ruling 9 в SQL-нотации описывает `key text PK`, `value_json`, `updated_at`. Реализовано
|
||||
по кодовой спецификации задачи и в конвенции кодовой базы этапа (колонки PascalCase — как у
|
||||
существующей `public.tenants` из скэффолда, без naming-convention пакета): колонки
|
||||
`Key` varchar(200) PK, `ValueJson` text NOT NULL, `UpdatedAt` timestamptz NOT NULL.
|
||||
Ровно те же соглашения применены к `users`/`sessions` (колонки = имена свойств).
|
||||
5. **Семантика FK.** В конфигурациях созданы реальные внешние ключи (в задаче они заявлены как
|
||||
«FK → ...»): `users.TenantId → tenants.Id` ON DELETE RESTRICT (защита от случайного каскадного
|
||||
сноса пользователей при удалении тенанта; удаления тенантов в этапе нет), `sessions.UserId →
|
||||
users.Id` ON DELETE CASCADE (сессии — транзитивные данные пользователя). Поведение в этапе 1
|
||||
ничем не упражняется — зафиксировано для Task 3+.
|
||||
|
||||
## Прочее
|
||||
- `TenantDbContext` в DI не регистрировался (только класс + design-time фабрика) — по задаче.
|
||||
- `Deal.Api/Program.cs` не менялся; для сборки дополнительные `using` не понадобились.
|
||||
- `DealDbDesignTimeFactory.cs` — без изменений (уже соответствует требованию «как сейчас»).
|
||||
- Другие модули/фронтенд/`backend` не тронуты; `.editorconfig`/`Directory.Build.props` не менялись.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Task 3 — Модуль Tenants: прикладные сервисы аутентификации и тенантов. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
|
||||
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS (6 старых + 19 новых, требуется ≥6).
|
||||
|
||||
## Итог
|
||||
|
||||
Реализован модуль `Deal.Modules.Tenants` по паттерну «port & adapter» (Ruling 1): прикладные
|
||||
сервисы аутентификации (`AuthService`) и реестра тенантов (`TenantService`), порты
|
||||
`IPasswordHasher`/`IAuthStore`/`ITenantRepository`/`ITenantProvisioner`, record-DTO
|
||||
(`Application/Models`). Модуль НЕ содержит EF и НЕ ссылается на Infrastructure (проверено grep).
|
||||
EF-адаптеры (`AuthStore`, `TenantRepository`) добавлены в `Deal.Infrastructure`
|
||||
(+ ProjectReference на модуль, циклов нет). Хэш пароля — Argon2id пакетом
|
||||
`Isopoh.Cryptography.Argon2` 2.0.0 через `Argon2.Hash/Verify` (Ruling 5); токен сессии —
|
||||
32 байта Base64Url, в БД — SHA-256 hex (Ruling 6); сессия 30 дней; смена пароля инвалидирует
|
||||
все сессии и выдаёт свежую (семантика `backend/app/auth.py` + `auth_routes.change`).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`)
|
||||
- `IPasswordHasher.cs` — порт: `Hash(password)` → encoded-строка; `Verify(password, encoded)`.
|
||||
- `DefaultPasswordHasher.cs` — Argon2id (Ruling 5). Вызовы `Argon2.Hash(password)` /
|
||||
`Argon2.Verify(encoded, password)` — это дефолты библиотеки 2.0.0: Argon2id
|
||||
(`Argon2Type.HybridAddressing` — подтверждено исходником v2.0.0), соль 16 случайных байт,
|
||||
t=3, m=65536 (64 MiB), p=1, длина хэша 32 байта. Комментарий про дефолты — в XML-doc.
|
||||
- `SessionTokens.cs` — `NewToken()` (32 байта `RandomNumberGenerator` → Base64Url без padding,
|
||||
43 символа) и `HashToken(raw)` (SHA-256 hex, 64 символа).
|
||||
- `Application/Models/StoredUserDto.cs`, `UserIdentityDto.cs`, `SessionDto.cs`,
|
||||
`TenantRecordDto.cs`, `LoginResultDto.cs`, `ChangePasswordResultDto.cs` — record, 1 тип = 1 файл;
|
||||
коды ошибок смены пароля — public-константы на `ChangePasswordResultDto`
|
||||
(`ErrorOldPassword = "oldPassword"`, `ErrorTooShort = "tooShort"`).
|
||||
- `IAuthStore.cs` — порт хранилища: 8 async-методов с `CancellationToken` (поиск по логину/токену/id,
|
||||
create/delete сессий, update хэша, очистка протухших). Модификаторы интерфейса явные (`public`),
|
||||
как требует `.editorconfig` (IDE0040) и существующий `ITenantContext`.
|
||||
- `AuthService.cs` — login/logout/changePassword/resolveSession + private helper
|
||||
`CreateSessionForUserAsync`. Логин нормализуется `ToLowerInvariant().Trim()`. Бизнес-отказы —
|
||||
null/коды в DTO, исключений не бросает. `SessionLifetimeDays = 30`, `MinNewPasswordLength = 4`.
|
||||
resolveSession проверяет `ExpiresAt > UtcNow` и всегда вызывает `DeleteExpiredSessionsAsync`.
|
||||
- `ITenantRepository.cs` — `FindByIdAsync/CreateAsync/ListAsync`.
|
||||
- `ITenantProvisioner.cs` — `ProvisionAsync(TenantId, ct)` (реализация — Task 5).
|
||||
- `TenantService.cs` — `CreateTenantAsync` (Guid `"N"`, status "active", CreatedAt=UtcNow →
|
||||
репозиторий → `ITenantProvisioner.ProvisionAsync`) и `ListTenantsAsync`.
|
||||
- `TenantModuleRegistrar.cs` — `AddTenantsModule(this IServiceCollection)`: singleton
|
||||
`IPasswordHasher → DefaultPasswordHasher`, scoped `AuthService`/`TenantService`; адаптеры
|
||||
`IAuthStore`/`ITenantRepository`/`ITenantProvisioner` НЕ регистрируются (комментарий-обоснование
|
||||
времён жизни в XML-doc).
|
||||
|
||||
### Созданы — `src/core/Deal.Infrastructure/Persistence/Repositories/`
|
||||
- `AuthStore.cs` — EF-адаптер `IAuthStore` на `DealDbContext` (public.users/sessions):
|
||||
`FindSessionByTokenHashAsync` учитывает `ExpiresAt > UtcNow`; delete/update — через
|
||||
`ExecuteDeleteAsync`/`ExecuteUpdateAsync` (без трекинга); `DeleteExpiredSessionsAsync`
|
||||
удаляет `ExpiresAt <= UtcNow`. Маппинг DTO↔сущности вручную.
|
||||
- `TenantRepository.cs` — EF-адаптер `ITenantRepository` (public.tenants), список упорядочен
|
||||
по `CreatedAt`.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj` — добавлены PackageReference:
|
||||
`Isopoh.Cryptography.Argon2` 2.0.0, `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11.
|
||||
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
|
||||
`..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj` (циклов нет).
|
||||
|
||||
### Созданы — `src/core/tests/Deal.Tests.Unit/`
|
||||
- `PasswordHasherTests.cs` (4 теста), `SessionTokensTests.cs` (4), `AuthServiceTests.cs` (11),
|
||||
fakes: `FakeAuthStore.cs`, `FakePasswordHasher.cs` (по 1 типу в файле). Fake-хранилище
|
||||
НЕ фильтрует протухшие сессии при поиске — так проверяется, что сервис сам учитывает `ExpiresAt`.
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
```
|
||||
$ dotnet build Deal.sln --nologo
|
||||
Сборка успешно выполнено через 2,9 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
|
||||
$ dotnet test tests/Deal.Tests.Unit --nologo --no-build
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
|
||||
```
|
||||
|
||||
Grep-проверка изоляции модуля (по `src/core/Deal.Modules.Tenants/`):
|
||||
`Deal.Infrastructure|Microsoft.EntityFrameworkCore|Npgsql` → 0 совпадений;
|
||||
`using Deal.Infrastructure|using Microsoft.EntityFrameworkCore|using Npgsql` → 0 совпадений.
|
||||
|
||||
## Список тестов (новые, 19)
|
||||
|
||||
PasswordHasherTests:
|
||||
1. `Hash_ReturnsArgon2idEncodedStringDifferentFromPassword` — encoded ≠ пароль, префикс `$argon2id$`.
|
||||
2. `Verify_WithCorrectPassword_ReturnsTrue`.
|
||||
3. `Verify_WithWrongPassword_ReturnsFalse`.
|
||||
4. `Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt`.
|
||||
|
||||
SessionTokensTests:
|
||||
5. `NewToken_ReturnsUniqueLongEnoughTokens`.
|
||||
6. `NewToken_UsesBase64UrlAlphabetWithoutPadding` — 43 символа, без `= + /`.
|
||||
7. `HashToken_IsDeterministicHexSha256` — 64 hex, детерминирован.
|
||||
8. `HashToken_DifferentTokensProduceDifferentHashes`.
|
||||
|
||||
AuthServiceTests (fake IAuthStore + fake хэшер):
|
||||
9. `LoginAsync_WithValidCredentials_ReturnsTokenAndCreatesSession` — логин нормализуется
|
||||
(" Admin " → "admin"), сессия создана с SHA-256-хэшем токена и ExpiresAt в будущем.
|
||||
10. `LoginAsync_WithWrongPassword_ReturnsNullLoginAndToken`.
|
||||
11. `LoginAsync_WithUnknownLogin_ReturnsNullLoginAndToken`.
|
||||
12. `ChangePasswordAsync_WithWrongOldPassword_ReturnsOldPasswordError` — `"oldPassword"`, вызовов нет.
|
||||
13. `ChangePasswordAsync_WithShortNewPassword_ReturnsTooShortError` — `"tooShort"` (новый пароль "123").
|
||||
14. `ChangePasswordAsync_Success_InvalidatesOldSessionsAndCreatesFreshOne` — журнал вызовов
|
||||
ровно `delete-user-sessions → update-password-hash → create-session`; в хранилище одна новая
|
||||
сессия; старый raw-токен не резолвится, новый — резолвится на того же пользователя.
|
||||
15. `ResolveSessionAsync_WithExpiredSession_ReturnsNull` — протухшая сессия (fake вернул её) → null;
|
||||
`DeleteExpiredSessionsAsync` вызвана, сессия удалена.
|
||||
16. `ResolveSessionAsync_WithValidSession_ReturnsUser`.
|
||||
17. `ResolveSessionAsync_WithoutToken_ReturnsNull` (null и пробелы, вызовов нет).
|
||||
18. `LogoutAsync_WithToken_DeletesSession`.
|
||||
19. `LogoutAsync_WithoutToken_IsNoOp`.
|
||||
|
||||
Старые 6 тестов (Marker/TenantId/TenantEntity/TenantSchemaMigrator) — без изменений, PASS.
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS (6 старых + 19 новых, ≥6). ✅
|
||||
3. Стиль: 1 тип = 1 файл; XML-doc на public-типах и членах интерфейсов; явные модификаторы
|
||||
(в т.ч. `public` у членов интерфейсов — IDE0040); без магических чисел (именованные константы
|
||||
`SessionLifetimeDays`, `MinNewPasswordLength`, `RawTokenByteLength`, `ActiveStatus`, коды ошибок);
|
||||
комментарии на русском. ✅
|
||||
4. В модуле НЕТ using/ссылок на Deal.Infrastructure и EF — подтверждено grep. ✅
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Версия Isopoh зафиксирована 2.0.0** (latest stable, поставлена `dotnet add package`). API
|
||||
подтверждён по исходникам тега v2.0.0 и README пакета: требуемые задачей вызовы
|
||||
`Argon2.Hash(password)` / `Argon2.Verify(encoded, password)` существуют; по умолчанию вариант
|
||||
`HybridAddressing` (Argon2id, префикс encoded-строки `$argon2id$`), соль 16 байт, t=3, m=64 MiB,
|
||||
p=1. Требование «Argon2id, дефолты библиотеки» выполнено ровно так, как описано в задаче.
|
||||
2. **DI-регистрация адаптеров** (`IAuthStore`, `ITenantRepository`, `ITenantProvisioner`) в Task 3
|
||||
НЕ выполнялась: инструкция задачи (п. «Ключевые решения», файл 11–12) явно откладывает её в
|
||||
Deal.Api (Task 4/5), тогда как общая строка плана [L85] упоминала `ServiceCollectionExtensions.cs`
|
||||
в Infrastructure (файла в проекте нет). Следовал детальной спецификации задачи — регистрация
|
||||
адаптеров будет в `Deal.Api/Program.cs` в Task 4.
|
||||
3. **`TenantModuleRegistrar`** расположен в `Application/` (namespace `Deal.Modules.Tenants.Application`)
|
||||
— по списку файлов задачи (п. 11); в Task 4 достаточно `using Deal.Modules.Tenants.Application`.
|
||||
4. **`FindSessionByTokenHashAsync`** (adapter) дополнительно фильтрует по `ExpiresAt` (требование
|
||||
п. 12), а `AuthService.ResolveSessionAsync` независимо проверяет `ExpiresAt > now` (требование
|
||||
п. 6) — проверка в сервисе необходима для корректной работы с любым хранилищем и покрыта тестом 15.
|
||||
5. Стоимость Argon2 по дефолту библиотеки — 64 MiB памяти на хэш; тестовый набор целиком
|
||||
укладывается в ~5 с, ничего не переопределял (по заданию — дефолты).
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 4 curl-приёмка auth-эндпоинтов Deal.Api на :5080 (см. план Task 4, п.7).
|
||||
# Сценарий: health → login admin/admin (кука в jar-old) → me → неверный пароль (401) →
|
||||
# change-password (admin→admin2, новая кука в jar-new) → logout старой сессии → проверки
|
||||
# me/logins → возврат пароля admin. Вывод каждой команды печатается в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
CORE_DIR="C:/telbase/src/core"
|
||||
API_DIR="$CORE_DIR/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR_OLD="/tmp/task4-jar-old.txt"
|
||||
JAR_NEW="/tmp/task4-jar-new.txt"
|
||||
|
||||
rm -f "$JAR_OLD" "$JAR_NEW" /tmp/task4-api.log
|
||||
|
||||
echo "== 0. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > /tmp/task4-api.log 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем сервер (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep 10
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/health =="
|
||||
curl -s "$BASE_URL/api/health"
|
||||
echo
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 4. POST /api/auth/login {admin,wrong} — ожидаем 401 {detail} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"wrong"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"oldPassword":"admin","newPassword":"admin2"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
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"
|
||||
echo
|
||||
|
||||
echo
|
||||
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"
|
||||
echo
|
||||
|
||||
echo
|
||||
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"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 9. login admin/admin — ожидаем 401 =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin2"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"oldPassword":"admin2","newPassword":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 12. Финальная проверка: me с jar-new — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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).
|
||||
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; curl-приёмка на :5080 проходит целиком (два прогона подряд — второй подтверждает идемпотентность seed).
|
||||
|
||||
## Итог
|
||||
|
||||
`Deal.Api` получил полный HTTP-контракт auth 1:1 с прототипом (`auth_routes.py`): login/logout/me/change-password
|
||||
на группе `/api/auth`, httpOnly-кука `deal_session` (Ruling 6), пайплайн `CORS → SessionMiddleware → эндпоинты`,
|
||||
DI модуля и адаптеров, минимальный seed (Ruling 8). `SessionMiddleware` разрешает сессию по куке, кладёт
|
||||
`CurrentUser` в `HttpContext.Items` и выставляет tenant-контекст запроса; сама 401 не отвечает (pass-through).
|
||||
В `ITenantContext` добавлены `SetTenant`/`Reset` (сброс в finally после запроса).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — `src/core/Deal.Api/`
|
||||
- `Configuration/CookieOptions.cs` — настройки куки (секция `Cookies`): `Name="deal_session"`, `Days`, `Secure`.
|
||||
`Days` намеренно без код-дефолта: единственный источник «30» — конфигурация (см. XML-doc; константа
|
||||
`AuthService.SessionLifetimeDays` переедет в конфиг в Task 5).
|
||||
- `Http/CurrentUser.cs` — `sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status)`.
|
||||
- `Http/AuthHelpers.cs` — ключ `HttpContext.Items["CurrentUser"]` (public const), `SetCurrentUser`,
|
||||
`GetCurrentUser` (+ const сообщения 401). Сделано прагматично: эндпоинты сами проверяют null и отдают 401
|
||||
(tuple-хелпер `RequireUser` из формулировки задачи не понадобился — точек использования всего две).
|
||||
- `Middleware/SessionMiddleware.cs` — singleton; опции через `IOptionsMonitor<CookieOptions>`; на запрос —
|
||||
scope через `RequestServices` → `AuthService.ResolveSessionAsync`; при пользователе: `SetCurrentUser` +
|
||||
`_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")))`; `finally → Reset()`. Pass-through без 401.
|
||||
- `Endpoints/AuthEndpoints.cs` — `MapAuthEndpoints(this IEndpointRouteBuilder)`, группа `/api/auth`
|
||||
с тегом OpenAPI `auth`; логика ответов и сообщений по прототипу; выставление куки (httpOnly, SameSite=Lax,
|
||||
Path=/, MaxAge=`TimeSpan.FromDays(options.Days)`, Secure из конфига); удаление куки на logout.
|
||||
- `Endpoints/LoginRequest.cs`, `Endpoints/ChangePasswordRequest.cs` — record-тела (входящий JSON camelCase,
|
||||
System.Text.Json case-insensitive по умолчанию).
|
||||
- `Hosting/StartupSeed.cs` — минимальный seed (Ruling 8): `EnsureSeedAsync(sp, ct)`. Через scope: пуст ли
|
||||
`ITenantRepository.ListAsync` → создать тенанта `Default`/`active`; пользователь `admin` из env
|
||||
`DEAL_BOOTSTRAP_LOGIN/PASSWORD` (default admin/admin), хэш `IPasswordHasher`; идемпотентно (если
|
||||
пользователь есть — no-op). Провижинер не используется.
|
||||
- `Hosting/PendingTenantProvisioner.cs` — см. Отклонение 1.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Api/Program.cs` — `AddTenantsModule()` + `AddDealPersistence()`, временная регистрация
|
||||
`ITenantProvisioner` (Отклонение 1), `Configure<CookieOptions>(GetSection("Cookies"))`,
|
||||
`ConfigureHttpJsonOptions` (UnsafeRelaxedJsonEscaping — Отклонение 3), dev-CORS
|
||||
(`SetIsOriginAllowed(_ => true).AllowCredentials().AllowAnyHeader().AllowAnyMethod()` + комментарий про
|
||||
Cloudflare/прод), `UseCors` → `UseMiddleware<SessionMiddleware>` → `MapAuthEndpoints()`,
|
||||
`await StartupSeed.EnsureSeedAsync(...)` перед `Run()`. `public partial class Program` сохранён.
|
||||
- `src/core/Deal.Api/appsettings.json` + `appsettings.Development.json` — секция `Cookies`
|
||||
(Name/Days/Secure); ConnectionStrings в dev-файле не тронуты (Отклонение 2).
|
||||
- `src/core/Deal.Api/Deal.Api.csproj` — явный ProjectReference на `Deal.Modules.Tenants` (композиционный
|
||||
корень регистрирует модуль напрямую; до этого модуль был доступен только транзитивно через Infrastructure).
|
||||
- `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs` — в интерфейс добавлены `SetTenant(TenantId)` и
|
||||
`Reset()` (SetTenant был только на классе-реализации; middleware ходит через интерфейс).
|
||||
- `src/core/Deal.Infrastructure/Data/TenantContext.cs` — реализация `Reset()` (обнуляет AsyncLocal).
|
||||
|
||||
### Создан — `src/core/Deal.Infrastructure/`
|
||||
- `ServiceCollectionExtensions.cs` — `AddDealPersistence(this IServiceCollection)`: scoped
|
||||
`IAuthStore → AuthStore`, `ITenantRepository → TenantRepository` (порт&адаптер; XML-doc про времена жизни).
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
### `dotnet build Deal.sln --nologo`
|
||||
```
|
||||
Сборка успешно выполнено через 2,1 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
```
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,8 с
|
||||
```
|
||||
|
||||
### curl-приёмка (`sh .superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`, :5080)
|
||||
Скрипт: `ASPNETCORE_ENVIRONMENT=Development` + apphost `Deal.Api.exe --urls http://localhost:5080`,
|
||||
jar-файлы `/tmp/task4-jar-{old,new}.txt`, kill по завершении (trap EXIT). Выводы по шагам (1-й прогон):
|
||||
|
||||
```
|
||||
1. health → {"ok":true,"service":"deal"}
|
||||
2. login admin/admin (jar-old)
|
||||
Set-Cookie: deal_session=ndz84oBA55R8uM2bEcGI0orKB4fJ8jezKVcDcrGHf6M; max-age=2592000; path=/; samesite=lax; httponly
|
||||
→ 200 {"ok":true,"login":"admin"}
|
||||
3. me (jar-old) → 200 {"login":"admin","ok":true}
|
||||
4. login admin/wrong → 401 {"detail":"Неверный логин или пароль"}
|
||||
5. change-password admin→admin2 (jar-old, новая кука в jar-new)
|
||||
Set-Cookie: deal_session=jPJbNRfO_jh3WaOevTEhYSRlKV8gDD6llW7sIJxLzCk; ... httponly
|
||||
→ 200 {"ok":true}
|
||||
6. logout (jar-old) → 200 {"ok":true}
|
||||
7. me (jar-old) → 401 {"detail":"Требуется авторизация"}
|
||||
8. me (jar-new) → 200 {"login":"admin","ok":true}
|
||||
9. login admin/admin → 401 {"detail":"Неверный логин или пароль"}
|
||||
10. login admin/admin2 (jar-old) → 200 {"ok":true,"login":"admin"}
|
||||
11. change-password admin2→admin (jar-new) → 200 {"ok":true} + свежая кука
|
||||
12. me (jar-new) → 200 {"login":"admin","ok":true}
|
||||
```
|
||||
Кука в Set-Cookie: `httpOnly`, `samesite=lax`, `max-age=2592000` (30 дней), `path=/`. Сервер останавливается
|
||||
скриптом (проверено: после прогона порт :5080 не отвечает). Второй прогон подряд — успешен (идемпотентный seed).
|
||||
|
||||
### psql (после приёмки)
|
||||
```
|
||||
tenants: b15066ee-126a-4a3b-9c80-1b36a526d33d | Default | active
|
||||
users: admin | active | $argon2id$v=...
|
||||
sessions: 1 (последняя свежая сессия после финального change-password)
|
||||
```
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **DI: scoped `TenantService` не собирается до Task 5** — on-build валидация DI в Development падает:
|
||||
`Unable to resolve service for type 'ITenantProvisioner' while attempting to activate 'TenantService'`
|
||||
(реализация провижинера — файл Task 5). Решение: временная заглушка `Deal.Api/Hosting/PendingTenantProvisioner.cs`
|
||||
(бросает `InvalidOperationException` при вызове; в Task 4 провижининг не вызывается), регистрация в
|
||||
`Program.cs` с комментарием «удалить в Task 5».
|
||||
2. **`Cookies` добавлена и в базовый `appsettings.json`** (задача упоминала только Development-файл): иначе при
|
||||
`ASPNETCORE_ENVIRONMENT != Development` `Days` остался бы 0 (cookie session-only). В Dev-файле секция
|
||||
продублирована по заданию; env-переменные `Cookies__*` перекрывают обе.
|
||||
3. **JSON-энкодер `UnsafeRelaxedJsonEscaping`**: без него System.Text.Json экранирует кириллицу (`\uXXXX`),
|
||||
а прототип FastAPI отдаёт raw UTF-8 (проверено в curl: `{"detail":"Неверный логин или пароль"}`).
|
||||
Фронт парсит оба варианта; выбран байт-1:1 с прототипом.
|
||||
4. **Seed создаёт пользователя через `DealDbContext` напрямую**, а не через `IAuthStore`: в порте нет операции
|
||||
создания пользователя (он только читает/обновляет). Расширять интерфейс модуля ради временного seed не стали —
|
||||
зафиксировано в XML-doc `StartupSeed`. Тенант создаётся через `ITenantRepository`, существование пользователя
|
||||
проверяется через `IAuthStore.FindUserByLoginAsync`.
|
||||
5. **Алиасы `using CookieOptions = Deal.Api.Configuration.CookieOptions`** в 3 файлах: имя совпадает с
|
||||
`Microsoft.AspNetCore.Http.CookieOptions` (неявный using Web SDK) → CS0104. В `AuthEndpoints` второй алиас —
|
||||
`AspNetCoreCookieOptions` для типа из `Microsoft.AspNetCore.Http`.
|
||||
6. Login в ответе нормализуется в нижний регистр (поведение AuthService из Task 3, осознанно строже прототипа,
|
||||
который возвращает `body.login.strip()` как есть). На приёмку не влияет (admin → admin).
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
|
||||
3. curl-цепочка (п.7) проходит; кука httpOnly в заголовке Set-Cookie. ✅
|
||||
4. Стиль: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без магических строк/чисел
|
||||
(константы сообщений, имён env, префиксов). `.editorconfig`/`Directory.Build.props` и другие модули не тронуты. ✅
|
||||
|
||||
Скрипт приёмки оставлен: `.superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 5 функциональная приёмка: bootstrap + провижининг схемы при старте (см. план Task 5, п.6).
|
||||
# Сценарий: чистый старт (seed тенанта+admin, схема tenant_000..001 с settings и историей,
|
||||
# login admin/admin) → повторный старт (идемпотентность, без дублей и ошибок). Проект НЕ git.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
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"
|
||||
LOG_RUN1="/tmp/task5-api-run1.log"
|
||||
LOG_RUN2="/tmp/task5-api-run2.log"
|
||||
|
||||
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
rm -f "$LOG_RUN1" "$LOG_RUN2"
|
||||
|
||||
echo "============================================================"
|
||||
echo "== RUN 1: чистый старт (база пуста) =="
|
||||
echo "============================================================"
|
||||
cd "C:/telbase/src/core/Deal.Api" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN1" 2>&1 &
|
||||
PID1=$!
|
||||
|
||||
echo "-- ожидание старта (15 c) --"
|
||||
sleep 15
|
||||
|
||||
echo
|
||||
echo "== 1a. tenants (ожидаем 1 строку с фикс. id) =="
|
||||
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
|
||||
|
||||
echo
|
||||
echo "== 1b. users (ожидаем admin) =="
|
||||
$PSQL -c 'SELECT "Login" FROM public.users;'
|
||||
|
||||
echo
|
||||
echo "== 1c. схемы (ожидаем tenant_000...001) =="
|
||||
$PSQL -c '\dn'
|
||||
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
echo
|
||||
echo "== 1d. таблицы в схеме тенанта (ожидаем settings + __TenantMigrationsHistory) =="
|
||||
$PSQL -c "\\dt $SCHEMA.*"
|
||||
|
||||
echo
|
||||
echo "== 1d2. история tenant-миграций (ожидаем InitialTenant) =="
|
||||
$PSQL -c "SELECT \"MigrationId\" FROM \"$SCHEMA\".\"__TenantMigrationsHistory\";"
|
||||
|
||||
echo
|
||||
echo "== 1e. login admin/admin =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== хвост лога run1 (ошибки?) =="
|
||||
tail -n 8 "$LOG_RUN1"
|
||||
|
||||
echo
|
||||
echo "== остановка run1 (pid $PID1) =="
|
||||
kill "$PID1" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "== RUN 2: повторный старт (идемпотентность) =="
|
||||
echo "============================================================"
|
||||
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN2" 2>&1 &
|
||||
PID2=$!
|
||||
|
||||
echo "-- ожидание старта (12 c) --"
|
||||
sleep 12
|
||||
|
||||
echo
|
||||
echo "== 2a. tenants (ожидаем по-прежнему 1 строку) =="
|
||||
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
|
||||
|
||||
echo
|
||||
echo "== 2b. users (ожидаем по-прежнему admin, 1 строку) =="
|
||||
$PSQL -c 'SELECT "Login" FROM public.users;'
|
||||
|
||||
echo
|
||||
echo "== 2c. логин снова работает =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== хвост лога run2 (ошибок быть не должно) =="
|
||||
tail -n 8 "$LOG_RUN2"
|
||||
|
||||
echo
|
||||
echo "== остановка run2 (pid $PID2) =="
|
||||
kill "$PID2" 2>/dev/null
|
||||
sleep 2
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
echo "== done =="
|
||||
@@ -0,0 +1,175 @@
|
||||
# Task 5 — Провижининг схем тенантов и bootstrap при старте. Отчёт
|
||||
|
||||
Дата: 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; функциональная приёмка
|
||||
на :5080 — два старта: первый создаёт seed и схему, повторный идемпотентен (дублей и ошибок нет), login admin/admin работает.
|
||||
|
||||
## Итог
|
||||
|
||||
Реализован механизм провижининга схем тенантов (Ruling 3) и bootstrap при старте (Ruling 8):
|
||||
`TenantProvisioningService` создаёт схему `tenant_<id>` и применяет tenant-миграции с историей
|
||||
`__TenantMigrationsHistory` в схеме тенанта; `TenantBootstrapService` (IHostedService) при старте создаёт
|
||||
дефолтного тенанта с фиксированным id и пользователя admin из env, затем провижинит схемы всех тенантов реестра.
|
||||
Временные заглушки Task 4 (`StartupSeed`, `PendingTenantProvisioner`) удалены; seed-код больше не трогает
|
||||
`DealDbContext` в Api. «30 дней» унифицировано: код-дефолт куки ссылается на `AuthService.SessionLifetimeDays`.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs`
|
||||
Реализация `ITenantProvisioner` (порт модуля Tenants), зависимость — `ConnectionStringProvider`.
|
||||
`ProvisionAsync(TenantId, ct)`:
|
||||
1. Открывает `NpgsqlConnection` на базовой строке (`ForTenant(null)`) и выполняет
|
||||
`TenantSchemaMigrator.CreateSchemaSql(tenantId.SchemaName)` — `CREATE SCHEMA IF NOT EXISTS`
|
||||
(DDL-идентификатор экранирует хелпер). Соединение закрывается через `await using`.
|
||||
2. Собирает опции `TenantDbContext` на строке `ForTenant(tenantId)` (уже с `Search Path=tenant_<id>`) с
|
||||
`npgsql.MigrationsHistoryTable("__TenantMigrationsHistory", tenantId.SchemaName)` и вызывает `Database.MigrateAsync`.
|
||||
3. Идемпотентность + защита от гонки параллельных провижинингов одного тенанта: статический
|
||||
`ConcurrentDictionary<string, SemaphoreSlim>` по имени схемы (`GetOrAdd` + `WaitAsync` + try/finally `Release`).
|
||||
Больше никакой синхронизации не добавлено.
|
||||
Комментарий: dev-роль `deal` имеет DDL-права — приемлемо для dev; в проде у прикладной роли DDL нет,
|
||||
миграции применяет служебная роль.
|
||||
|
||||
### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs`
|
||||
`AddDealPersistence` дополнительно регистрирует `services.AddScoped<ITenantProvisioner, TenantProvisioningService>()`.
|
||||
`ConnectionStringProvider` в Infrastructure не регистрируется: он singleton в `Program.cs` (проверено).
|
||||
|
||||
### Изменён — `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs`
|
||||
Добавлен порт `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (XML-doc: создаёт пользователя,
|
||||
Id задаёт вызывающий) — seed через порт модуля, а не через DbContext в Api.
|
||||
|
||||
### Изменён — `src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs`
|
||||
Реализация `CreateUserAsync`: маппинг DTO → `UserEntity` (Id/Login/TenantId/Status/PasswordHash из DTO,
|
||||
`CreatedAt = UtcNow`) + `SaveChangesAsync`.
|
||||
|
||||
### Изменён — `src/core/Deal.Modules.Tenants/Application/TenantService.cs`
|
||||
Добавлена перегрузка `CreateTenantAsync(string name, Guid id, CancellationToken ct)` (для bootstrap с фиксированным
|
||||
id, Ruling 8); прежняя `CreateTenantAsync(name, ct)` делегирует в неё с `Guid.NewGuid()`. Общий код
|
||||
сохранения (`TenantRepository.CreateAsync`) + провижининг (`ITenantProvisioner.ProvisionAsync`) вынесен в перегрузку с id.
|
||||
|
||||
### Создан — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService)
|
||||
`StartAsync` (scope через `IServiceScopeFactory`):
|
||||
1. `ITenantRepository.ListAsync` — если тенантов нет, создаёт дефолтного: фикс. id `00000000-0000-0000-0000-000000000001`,
|
||||
имя `Default`, через `TenantService.CreateTenantAsync(name, id, ct)` (сам вызывает провижининг).
|
||||
2. Если пользователя с логином (после нормализации lowercase/trim из env `DEAL_BOOTSTRAP_LOGIN`, дефолт `admin`) нет —
|
||||
создаёт `StoredUserDto` (Id=`Guid.NewGuid()`, Login нормализованный, TenantId=фикс. id дефолтного, Status=`active`,
|
||||
PasswordHash=`IPasswordHasher.Hash(password)`) через `IAuthStore.CreateUserAsync`. Пароль из env
|
||||
`DEAL_BOOTSTRAP_PASSWORD` (дефолт `admin`).
|
||||
3. Для ВСЕХ тенантов реестра (включая свежесозданного — повтор идемпотентен) — `ITenantProvisioner.ProvisionAsync`.
|
||||
`StopAsync` — пуст (ничего не держим). Использует только порты модулей + `IPasswordHasher`; `DealDbContext` в Api нет.
|
||||
`IConfiguration` читается на уровне Api (env-креды).
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Program.cs`
|
||||
Убрана временная регистрация `ITenantProvisioner → PendingTenantProvisioner` и вызов
|
||||
`StartupSeed.EnsureSeedAsync` перед `Run`; добавлен `builder.Services.AddHostedService<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.Modules.Tenants/Application/AuthService.cs`
|
||||
`SessionLifetimeDays` стала `public const int SessionLifetimeDays = 30;` (XML-doc) — единый источник «30».
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Configuration/CookieOptions.cs`
|
||||
`public int Days { get; set; } = AuthService.SessionLifetimeDays;` — код-дефолт ссылается на константу модуля;
|
||||
XML-doc переписан (конфигурация `Cookies__Days` при необходимости перекрывает дефолт).
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Endpoints/AuthEndpoints.cs`
|
||||
Комментарий в `SetSessionCookie` приведён к новой реальности (MaxAge = `Cookies:Days`, код-дефолт — константа модуля).
|
||||
|
||||
### Изменены — `src/core/Deal.Api/appsettings.json`, `appsettings.Development.json`
|
||||
Из секции `Cookies` убран `"Days": 30` — «30» больше не дублируется в конфиге; значение по умолчанию течёт из
|
||||
`AuthService.SessionLifetimeDays` (см. CookieOptions).
|
||||
|
||||
### Изменён — `src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs`
|
||||
Добавлена реализация `CreateUserAsync` (добавляет пользователя в in-memory список и пишет `create-user:{id}` в `Calls`) —
|
||||
интерфейс `IAuthStore` расширен, тест-дублёр обязан его реализовать.
|
||||
|
||||
### Создан — `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh`
|
||||
Скрипт функциональной приёмки: два старта Deal.Api на :5080 с psql/curl-проверками и остановкой (см. ниже).
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
### `dotnet build Deal.sln --nologo`
|
||||
```
|
||||
Сборка успешно выполнено через 2,0 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
```
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
|
||||
```
|
||||
|
||||
### Функциональная приёмка (порт 5080)
|
||||
База очищена заранее: `DELETE FROM public.users; DELETE FROM public.tenants;` (порядок важен: sessions
|
||||
каскадно удаляются при удалении users; FK users→tenants — RESTRICT). В `public` до старта: 0 тенантов, 0 пользователей.
|
||||
|
||||
**RUN 1 — чистый старт** (`Deal.Api.exe`, `ASPNETCORE_ENVIRONMENT=Development`, ожидание 15 c):
|
||||
|
||||
a. Тенанты — 1 строка с фиксированным id:
|
||||
```
|
||||
Id | Name | Status
|
||||
--------------------------------------+---------+--------
|
||||
00000000-0000-0000-0000-000000000001 | Default | active
|
||||
```
|
||||
b. Пользователи:
|
||||
```
|
||||
Login
|
||||
-------
|
||||
admin
|
||||
```
|
||||
c. Схемы (`\dn`): `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
|
||||
d. Таблицы схемы тенанта (`\dt tenant_00000000000000000000000000000001.*`):
|
||||
`__TenantMigrationsHistory` и `settings`. История миграций содержит одну строку:
|
||||
```
|
||||
20260905193010_InitialTenant
|
||||
```
|
||||
e. Логин:
|
||||
```
|
||||
{"ok":true,"login":"admin"}
|
||||
HTTP 200
|
||||
```
|
||||
Ошибок/предупреждений в логе run1 нет (grep `error|exception|fail|warn|crit` — пусто). Bootstrap-последовательность в логе:
|
||||
`SELECT tenants (пусто)` → `INSERT tenants` → `SELECT users` → `INSERT users` → `SELECT tenants` → старт слушателя.
|
||||
|
||||
**RUN 2 — повторный старт (идемпотентность):**
|
||||
|
||||
- Тенанты: по-прежнему 1 строка (`00000000-...-0001`, Default) — дублей нет.
|
||||
- Пользователи: по-прежнему 1 (`admin`) — дублей нет.
|
||||
- Логин снова: `{"ok":true,"login":"admin"}` HTTP 200.
|
||||
- Лог run2: только `SELECT`-проверки (tenants → users → tenants для провижининга) — INSERT-ов seed нет,
|
||||
ошибок/предупреждений нет (идемпотентность подтверждена).
|
||||
- Оба процесса остановлены (kill + taskkill), порт :5080 свободен, процессов `Deal.Api` не осталось.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Запуск не через `dotnet run`, а напрямую apphost `Deal.Api.exe`** (эквивалент `dotnet run --no-build`):
|
||||
`dotnet run` порождает дочерний процесс приложения, который переживает kill родителя и «залипает» на порту;
|
||||
прямой запуск собранного exe из Task 4 делал остановку детерминированной. Код тот же (сборка свежая, `--no-build` не требуется).
|
||||
2. **psql-запросы используют кавычки для PascalCase-колонок** (`"Id"`, `"Name"`, `"Status"`, `"Login"`): Task 2
|
||||
оставил колонки в именах C#-свойств (EF default, без snake_case). Буквальные команды из задания (`SELECT id, ...`)
|
||||
падают с `column "id" does not exist`. Функциональный смысл проверок a–e выполнен; переименование колонок —
|
||||
вне scope Task 5.
|
||||
3. **Из appsettings убран `Cookies:Days`** (см. задание «уберите дублирование „30“»): конфиг-секция больше не
|
||||
дублирует число «30»; дефолт теперь единственный — `AuthService.SessionLifetimeDays`. Поведение не изменилось
|
||||
(кука остаётся `max-age=2592000`, проверено приёмкой Task 4 ранее; здесь логин/Set-Cookie отработали на новом дефолте).
|
||||
Значение можно перекрыть env `Cookies__Days`.
|
||||
4. **В лог EF не пишутся DDL-команды провижининга** (CREATE SCHEMA выполняется сырым ADO-командами, DDL миграций —
|
||||
внутренним исполнителем EF без Command-событий 20101): факт применения подтверждён psql (таблицы и строка
|
||||
`InitialTenant` в истории схемы), ошибок в логах нет.
|
||||
5. **Двойной провижининг свежесозданного тенанта в первом старте**: `CreateTenantAsync` уже провижинит схему,
|
||||
затем цикл по всем тенантам вызывает `ProvisionAsync` повторно — по заданию допустимо («можно пропустить
|
||||
свежесозданного»), выбрана более простая ветка «провижинить всех»: повтор идемпотентен.
|
||||
|
||||
## Проверки (Acceptance Task 5)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
|
||||
3. Старт с чистой БД: tenants — строка с фикс. id `00000000-0000-0000-0000-000000000001`, users — `admin`. ✅
|
||||
4. Схема `tenant_00000000000000000000000000000001` создана; содержит `settings` и `__TenantMigrationsHistory`
|
||||
с `InitialTenant`. ✅
|
||||
5. Повторный старт идемпотентен: дублей нет (tenants/users по 1), ошибок в логах нет. ✅
|
||||
6. Login admin/admin работает после старта: `{"ok":true,"login":"admin"}`. ✅
|
||||
7. Стиль: 1 тип = 1 файл; XML-doc на public-контрактах; комментарии на русском; «30» — единый источник
|
||||
(public const модуля); заглушки Task 4 удалены. Сторонние модули/`backend/` не тронуты. ✅
|
||||
|
||||
Скрипт приёмки: `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh` (оставлен, как task-4-скрипт).
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 6 финальная приёмка этапа 1 (см. план Task 6, п.2): полная curl-приёмка
|
||||
# (health, login, me, logout) + psql-проверка схем. Проект НЕ git.
|
||||
# Предполагается: postgres из deploy/compose.dev.yml поднят, БД deal с системными миграциями.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task6-jar.txt"
|
||||
LOG="/tmp/task6-api.log"
|
||||
PSQL="docker exec deal-postgres psql -U deal -d deal"
|
||||
|
||||
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
rm -f "$JAR" "$LOG"
|
||||
|
||||
echo "== запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== остановка сервера (pid $PID) =="
|
||||
kill "$PID" 2>/dev/null
|
||||
sleep 2
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep 12
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/health — ожидаем 200 {ok:true,service:deal} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" "$BASE_URL/api/health"
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR" "$BASE_URL/api/auth/me"
|
||||
|
||||
echo
|
||||
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"
|
||||
|
||||
echo
|
||||
echo "== 5. psql: схемы (\dn) =="
|
||||
$PSQL -c '\dn'
|
||||
|
||||
echo
|
||||
echo "== 6. psql: таблицы public (\dt public.*) =="
|
||||
$PSQL -c '\dt public.*'
|
||||
|
||||
echo
|
||||
echo "== 7. psql: таблицы схемы тенанта =="
|
||||
$PSQL -c '\dt tenant_00000000000000000000000000000001.*'
|
||||
|
||||
echo
|
||||
echo "== 8. psql: применённые миграции =="
|
||||
$PSQL -c 'SELECT "MigrationId" FROM public."__EFMigrationsHistory";'
|
||||
$PSQL -c 'SELECT "MigrationId" FROM tenant_00000000000000000000000000000001."__TenantMigrationsHistory";'
|
||||
|
||||
echo
|
||||
echo "== хвост лога API (ошибок/предупреждений быть не должно) =="
|
||||
tail -n 15 "$LOG"
|
||||
|
||||
echo
|
||||
echo "== done =="
|
||||
@@ -0,0 +1,97 @@
|
||||
# Task 6 — Финал этапа: техдок и полная проверка. Отчёт
|
||||
|
||||
Дата: 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,
|
||||
curl-приёмка health/login/me/logout на :5080 — все 200, psql-схемы и миграции на месте, `scripts/build.sh`
|
||||
и `scripts/test.sh` успешны.
|
||||
|
||||
## 1. Изменения в техдоке `docs/technical/Техническая-документация-Дейл.md`
|
||||
|
||||
Код/конфиги продукта не менялись. Только три точечные правки техдока:
|
||||
|
||||
1. **Новый раздел `## 13. Быстрый старт (dev, актуально для этапа 1)`** (в конец, после раздела 12) —
|
||||
фактический dev-путь этапа 1:
|
||||
- Postgres: `docker compose -f deploy/compose.dev.yml up -d` (контейнер `deal-postgres`, порт 5433, БД `deal`);
|
||||
- системные миграции из `src/core`: `dotnet ef database update --project Deal.Infrastructure
|
||||
--startup-project Deal.Api --context DealDbContext` (public: tenants/users/sessions, `InitialSystem`);
|
||||
- запуск: `dotnet run --project Deal.Api --urls http://localhost:5080` — при старте `TenantBootstrapService`
|
||||
(идемпотентно) создаёт дефолтного тенанта `00000000-0000-0000-0000-000000000001` (схема
|
||||
`tenant_00000000000000000000000000000001` с `settings`, миграция `InitialTenant`) и пользователя `admin`
|
||||
(env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`);
|
||||
- проверка auth: `POST /api/auth/login` → `{"ok":true,"login":"admin"}`, кука `deal_session` (30 дней,
|
||||
источник — константа `AuthService.SessionLifetimeDays`, перекрытие `Cookies__Days`); прочие эндпоинты
|
||||
и `/api/health`;
|
||||
- psql-проверки схем: `\dn`, `\dt public.*`, `\dt tenant_*.*`;
|
||||
- сборка/тесты: `sh scripts/build.sh`, `sh scripts/test.sh`.
|
||||
2. **Раздел 11 «Известные ограничения и TODO»** — добавлен блок «Выполнено на этапе 1 (2026-09-05)»:
|
||||
доступ/сессии (login/logout/me/change-password, кука 30 дней) и мультитенантность/миграции
|
||||
(`InitialSystem`, схемы `tenant_<id>` с `InitialTenant`, провижининг + bootstrap). Остальные TODO
|
||||
не переписаны, помечены заголовком «Остаётся TODO».
|
||||
3. **Раздел 4 «Мультитенантность и БД»** — добавлен подраздел «Фактическая схема на конец этапа 1» с
|
||||
фактическими таблицами: `public.tenants/users/sessions` (колонки по конвенции EF Core, PascalCase) и
|
||||
`settings` в схеме тенанта, история миграций, автоматический провижининг/bootstrap. Forward-looking
|
||||
списки («Ключевые таблицы public/схемы тенанта (пример)») сохранены без изменений, явно помечены как
|
||||
целевой вид будущих этапов.
|
||||
|
||||
## 2. Полная проверка этапа
|
||||
|
||||
Все команды выполнены фактически (не по памяти):
|
||||
|
||||
### `dotnet build Deal.sln --nologo` (из `src/core`)
|
||||
```
|
||||
Сборка успешно выполнено через 1,9 с
|
||||
```
|
||||
0 warnings / 0 errors — гарантировано `TreatWarningsAsErrors=true` в `Directory.Build.props`.
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,9 с
|
||||
```
|
||||
|
||||
### `dotnet ef migrations list` (оба контекста, `--no-connect`)
|
||||
- `DealDbContext`: `20260905192825_InitialSystem`; `TenantDbContext`: `20260905193010_InitialTenant`.
|
||||
Применение подтверждено psql (строки в `__EFMigrationsHistory` / `__TenantMigrationsHistory`).
|
||||
|
||||
### psql (`docker exec deal-postgres psql -U deal -d deal`)
|
||||
- `\dn`: схемы `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
|
||||
- `\dt public.*`: `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (системная миграция применена).
|
||||
- `\dt tenant_00000000000000000000000000000001.*`: `settings`, `__TenantMigrationsHistory`
|
||||
(миграция `InitialTenant` применена на схему).
|
||||
- Данные: 1 тенант (`00000000-0000-0000-0000-000000000001`, Default, active), 1 пользователь (`admin`, active).
|
||||
|
||||
### Живой прогон API на :5080 (скрипт `.superpowers/sdd/deal-stage1-tenancy/task-6-functional-check.sh`)
|
||||
Запуск `Deal.Api.exe` (apphost, Development) — bootstrap отработал идемпотентно (дублей нет, ошибок в логе нет):
|
||||
- `GET /api/health` → `{"ok":true,"service":"deal"}` HTTP 200;
|
||||
- `POST /api/auth/login` {admin,admin} → `{"ok":true,"login":"admin"}` HTTP 200 + кука;
|
||||
- `GET /api/auth/me` с кукой → `{"login":"admin","ok":true}` HTTP 200;
|
||||
- `POST /api/auth/logout` → `{"ok":true}` HTTP 200.
|
||||
Процесс остановлен (kill + taskkill), проверено: процессов `Deal.Api` нет, порт освобождён.
|
||||
|
||||
### `sh scripts/build.sh && sh scripts/test.sh` (из корня репозитория)
|
||||
- build.sh: `Сборка успешно выполнено` (0/0);
|
||||
- test.sh: `Сводка теста: всего: 25; сбой: 0; успешно: 25`.
|
||||
|
||||
## 3. Отклонения и решения
|
||||
|
||||
1. **Запуск приёмки — через собранный apphost `Deal.Api.exe`, а не `dotnet run`** — как в Task 4/5:
|
||||
`dotnet run` оставляет переживающий kill дочерний процесс; прямой запуск делает остановку
|
||||
детерминированной. Код тот же (свежая сборка).
|
||||
2. **Создан артефакт-скрипт приёмки `task-6-functional-check.sh`** (по образцу task-4/task-5) — это не код
|
||||
и не конфиг продукта, а фиксация прогона в `.superpowers/sdd/`.
|
||||
3. **Раздел 11 не содержал буквальных пунктов «про login»/«про tenant-схемы»** (там были только общие TODO) —
|
||||
выполненные пункты добавлены отдельным блоком «Выполнено на этапе 1», остальной список не тронут.
|
||||
4. **Раздел 4 уже упоминал users в forward-looking списке**, поэтому фактическая схема этапа 1 (включая
|
||||
`sessions` и tenant `settings`, которых в целевом списке нет) зафиксирована отдельным подразделом
|
||||
«Фактическая схема на конец этапа 1» — forward-looking текст не удалялся.
|
||||
5. **В техдоке указаны фактические (PascalCase) имена колонок** — по конвенции EF Core, как в БД
|
||||
(см. task-5-report, отклонение 2).
|
||||
6. `dotnet ef migrations list` выполнялся с `--no-connect` (нет гарантии env для development-конфига);
|
||||
факт применения подтверждён psql-запросами к таблицам истории — расхождений нет.
|
||||
|
||||
## 4. Acceptance Task 6
|
||||
|
||||
1. `scripts/build.sh` / `scripts/test.sh` успешны; `migrations list` — System: InitialSystem, Tenant: InitialTenant. ✅
|
||||
2. Полная curl-приёмка: health, login, me, logout — все HTTP 200; psql-проверка схем на месте. ✅
|
||||
3. Техдок обновлён: раздел «Быстрый старт dev» (актуальные шаги), раздел 11 (выполнено на этапе 1),
|
||||
раздел 4 (фактическая схема). ✅
|
||||
4. `task-6-report.md` написан; финальная строка в `progress.md` (`## Task status`). ✅
|
||||
@@ -0,0 +1,57 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md
|
||||
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger.
|
||||
|
||||
## Todos
|
||||
- [x] T1: Аудит действий (входы/выходы/действия пользователей)
|
||||
- [x] T2: История расхода токенов (token_usage_events)
|
||||
- [x] T3: Аналитика (operator/analytics/*) + контракт
|
||||
- [x] T4: Оператор-консоль (фронт)
|
||||
- [x] T5: Страница активации инвайта (фронт)
|
||||
- [x] T6: Наблюдаемость ELK/Loki (Grafana-дашборды)
|
||||
- [x] T7: Приёмка/доки
|
||||
|
||||
## Task status
|
||||
|
||||
- **T1 (аудит)**: complete. `AuditEvents` + 16 констант; `Deal.Api/Http/AuditAppender.cs` — единая точка
|
||||
записи (актор tenant/operator, IP, без секретов). Пишутся: выход тенанта и оператора, `invite_joined`,
|
||||
CRUD карточек/комментарии, CRUD контейнеров, сохранение настроек (только имена полей), включение канала,
|
||||
привязка Telegram. Отчёт: task-b1-report.md.
|
||||
- **T2 (токены)**: complete. Таблица `public.token_usage_events` + миграция
|
||||
`20260910152246_AddTokenUsageEvents`; порт `ITokenUsageEventStore` + сервис + EF-адаптер; запись в точке
|
||||
списания AI (`TokenUsageRecorder`) и ML (`GrpcMlClient`, оценка ≈chars/4). Применена к dev-Postgres.
|
||||
- **T3 (аналитика)**: complete. `/api/operator/analytics/{overview,tokens,activity}` + расширенный
|
||||
`/api/operator/audit` (actorId/offset/total). Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`.
|
||||
Отчёт: task-b2-report.md.
|
||||
- **T4/T5 (фронт)**: complete. Hash-роутер без зависимостей (`#/`, `#/operator`, `#/join?code=…`);
|
||||
разделы оператора (вход, тенанты+suspend/resume/impersonate, инвайты, лимиты, аудит, аналитика, health);
|
||||
страница активации инвайта. Отчёты: task-f1-report.md, task-f2-report.md.
|
||||
Доправка координатора: impersonation теперь ставит httpOnly-куку `deal_session` на ответе
|
||||
(`Deal.Api/Http/SessionCookieWriter.cs`, использован и в AuthEndpoints) — UI переходит в приложение,
|
||||
JS-токен-обходной путь убран.
|
||||
- **T6 (наблюдаемость)**: complete. Grafana provisioning: datasource Loki + дашборды
|
||||
`Deal-Auth/Errors/Rps/Logs`; promtail `pipeline_stages` (json → label level); раздел техдока.
|
||||
- **T7 (приёмка/доки)**: complete. Runtime-приёмка — см. раздел ниже. Доки: обновлены `docs/api/api-map.md`
|
||||
(операторские ручки + аналитика, UI-пометка), `docs/technical/Техническая-документация-Дейл.md` (таблица
|
||||
`token_usage_events`, оператор-консоль/аналитика/каталог аудита — §4 и §13.10), `docs/user-guide/
|
||||
Инструкция-пользователя-Дейл.md` (активация инвайта + раздел оператора), `docs/superpowers/STATUS.md`
|
||||
(этап 10 в таблице, раздел «сделано», Manual-остаток).
|
||||
|
||||
## Runtime-приёмка (Docker dev-стек) — ВЫПОЛНЕНА
|
||||
|
||||
- Системная миграция `AddTokenUsageEvents` применена к dev-Postgres (:5433).
|
||||
- `deal-core` пересобран и перезапущен; healthy.
|
||||
- Операторский вход `operator`/`operator` → 200 `{ok:true}`; `GET /api/operator/tenants` → 200.
|
||||
- `GET /api/operator/analytics/overview` → `{tenantsTotal:2, tenantsActive:2, events:17, logins:11,
|
||||
failedLogins:1}`.
|
||||
- `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/audit` → 200.
|
||||
- Фронт: `npm run build` зелёный; dev-сервер отдаёт `#/operator` и `#/join`. Ядро/тесты: build 0/0,
|
||||
core **1173/1173 PASS**.
|
||||
|
||||
## Остатки/ограничения
|
||||
|
||||
- `channel_created` зарезервирован каталога, но не эмитится (нет пользовательской ручки создания канала).
|
||||
- Идентичность в логах: access-лог пишет метод/путь/код (без login); полный аудит с актором — в `audit_log`.
|
||||
- Реальные Telegram/LLM-креды — вне рамок (по решению владельца).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Task B1 report — аудит действий (T1) и история расхода токенов (T2)
|
||||
|
||||
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`.
|
||||
|
||||
## T1. Аудит действий (входы/выходы/действия пользователей)
|
||||
|
||||
### Каталог событий
|
||||
`Deal.Modules.Tenants/Application/AuditEvents.cs` — добавлены стабильные строковые константы:
|
||||
`tenant_logout`, `operator_logout`, `invite_joined`, `card_created`, `card_moved`, `card_trashed`,
|
||||
`card_restored`, `card_deleted`, `card_comment_added`, `container_created`, `container_updated`,
|
||||
`container_deleted`, `settings_updated`, `channel_enabled`, `channel_created`, `telegram_linked`.
|
||||
Значение `invite_activated` оставлено в каталоге как легаси этапа 7 (исторические данные);
|
||||
`POST /api/join` теперь пишет `invite_joined`.
|
||||
|
||||
### Единая точка и актор
|
||||
- Запись — только через существующий `AuditService` (append-only `public.audit_log`).
|
||||
- Новый хелпер `Deal.Api/Http/AuditAppender.cs`: `AppendTenantAsync` (актор `tenant` из разрешённой
|
||||
сессии: userId/tenantId + IP) и `AppendOperatorAsync` (актор `operator`). Детали — минимальные, без
|
||||
секретов. Резолвит `AuditService` из `RequestServices` (scoped, `DealDbContext` системной схемы
|
||||
`public` доступен в tenant-запросе — архитектура не менялась).
|
||||
|
||||
### Точки записи
|
||||
| Событие | Место |
|
||||
|---|---|
|
||||
| `tenant_logout` | `AuthEndpoints.LogoutAsync` (при живой сессии) |
|
||||
| `operator_logout` | `OperatorAuthEndpoints.LogoutAsync` |
|
||||
| `invite_joined` | `JoinEndpoint` (актор — новый пользователь тенанта) |
|
||||
| `card_created` | `CardDetailsEndpoints.CreateCardAsync` |
|
||||
| `card_moved` / `card_trashed` / `card_restored` / `card_deleted` / `card_comment_added` | `CardsEndpoints` |
|
||||
| `container_created` / `container_updated` / `container_deleted` | `ContainersEndpoints` (принятие ИИ-предложения — `container_updated`) |
|
||||
| `settings_updated` | `SettingsEndpoints.PatchSettingsAsync` (в деталях только имена полей — без значений/секретов) |
|
||||
| `channel_enabled` | `TelegramEndpoints` (`SetMonitorAsync`, `MonitorAllAsync` при включении) |
|
||||
| `telegram_linked` | `TelegramEndpoints` (фаза `ready` после start-qr/send-code/send-password) |
|
||||
|
||||
`channel_created` — константа каталога (резерв): пользовательской ручки создания канала пока нет,
|
||||
каталог наполняется синхронизацией Telegram (системное действие, актор не `tenant`).
|
||||
|
||||
## T2. История расхода токенов (`public.token_usage_events`)
|
||||
|
||||
### Схема (системная, `--context DealDbContext`)
|
||||
- `Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs` + `TokenUsageEventConfiguration.cs`.
|
||||
- Таблица `public.token_usage_events`: `Id` (bigint identity), `TenantId` (uuid), `At` (timestamptz),
|
||||
`Provider`/`Model`/`Kind` (text), `PromptTokens`/`CompletionTokens`/`TotalTokens` (bigint),
|
||||
`DetailJson` (text). Индексы `(TenantId, At)` и `(At)`; FK → `public.tenants` (Restrict).
|
||||
- Миграция: `Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs`
|
||||
(каталог вывода — как у существующих системных миграций `DealDbContext`), снапшот обновлён.
|
||||
|
||||
### Модуль/порт/адаптер
|
||||
- Модели: `TokenUsageEventDto`, `TokenUsageEventQueryDto`, `TokenUsageAggregateDto`,
|
||||
`TokenUsageEventKinds` (`ai|ml`), `TokenUsageGroupBys` (`day|tenant|provider|model`), `TokenUsageSources`.
|
||||
- Порт `ITokenUsageEventStore` (append-only + агрегаты), сервис `TokenUsageEventService`
|
||||
(единая точка записи, `At=UTC-now`), EF-адаптер `TokenUsageEventStore`.
|
||||
- Регистрация: `AddDealPersistence` → `ITokenUsageEventStore`; `AddTenantsModule` → `TokenUsageEventService`.
|
||||
- `TokenUsageRecorder` расширен: пишет событие истории для AI (`AddAsync(usage, provider, model, ct)`)
|
||||
и для ML (`AddEstimatedAsync(text, provider, model, ct)` — оценка ≈chars/4, конвенция ai.proto;
|
||||
бюджет/lifetime `aiTokenUsage` ML не затрагивает, т.к. вызов локальный).
|
||||
- AI-путь: `GrpcAiClassifier`/`GrpcAiTools` передают provider/model из `AiProviderConfigBuilder`.
|
||||
- ML-путь: `GrpcMlClient.PredictAsync` пишет событие `kind=ml`, provider=`local`, model=`ml`.
|
||||
`LocalMlClient` (dev-заглушка без реального ML) событий не пишет — как и Local AI fallback.
|
||||
- `TokenUsageRecorder` регистрируется в `AddDealIntegrations` независимо от AI-режима.
|
||||
|
||||
## Валидация
|
||||
- `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors.
|
||||
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные.
|
||||
- Новые тесты: `AuditEventsTests` (стабильность строк), `TokenUsageRecorderTests` (AI + ML события,
|
||||
оценка токенов), `TokenUsageEventServiceTests`, `FakeTokenUsageEventStore`; обновлены
|
||||
`TokenUsageRecorderTests`/`GrpcAi*Tests`/`PipelineWorkerGrpcAiTests`/`GrpcMlClientTests`/
|
||||
`MlOutboxFlushSchedulerTests`/`IntegrationsDiTests` под новые зависимости.
|
||||
|
||||
## Замечания / что осталось
|
||||
- Трансляция `groupBy=day` (`DateTimeOffset.Year/Month/Day` → `date_part`) проверена сборкой и
|
||||
фейк-хранилищем; живая проверка SQL-плана — при поднятом Postgres (Docker).
|
||||
- `channel_created` не эмитится (нет ручки создания канала) — задокументировано как резерв каталога.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task B2 report — аналитика оператора (T3)
|
||||
|
||||
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`.
|
||||
|
||||
## Эндпоинты
|
||||
`Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs` — группа `/api/operator/analytics` (операторская сессия,
|
||||
read-only, 401 «Требуется вход оператора» без сессии):
|
||||
- `GET /overview` — тенанты всего/активных, расход токенов за период, число событий,
|
||||
входы/выходы/неудачные входы.
|
||||
- `GET /tokens?groupBy=day|tenant|provider|model&tenantId=&from=&to=` — серия/агрегаты токенов + `total`.
|
||||
- `GET /activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` — лента действий
|
||||
(`items`/`total`/`limit`/`offset`).
|
||||
|
||||
Расширен `GET /api/operator/audit` (`OperatorAuditEndpoints`): фильтр `actorId`, `offset`; ответ
|
||||
`{items, total}` обратно совместим (добавлены только query-параметры, поля ответа не менялись).
|
||||
|
||||
## Прикладной слой
|
||||
- `Deal.Modules.Tenants/Application/AnalyticsService.cs` (scoped): `OverviewAsync`, `TokensAsync`,
|
||||
`ActivityAsync`, `NormalizeActivityLimit` (дефолт 100, кламп 1..500).
|
||||
Источники: `ITenantRepository`, `AuditService`, `TokenUsageEventService`.
|
||||
- DTO: `AnalyticsOverviewDto`, `AnalyticsTokensDto`, `AnalyticsActivityDto`
|
||||
(camelCase на wire, времена — ISO-8601).
|
||||
- `AuditQueryDto` расширен `ActorId` и `Offset` (опциональные, в конце — обратная совместимость).
|
||||
`AuditLogStore`/`FakeAuditLogStore`: фильтр `ActorId` и `Skip(Offset)`; `CountAsync` — без offset.
|
||||
- Регистрация: `AnalyticsService` — в `AddTenantsModule`; эндпоинт замаплен в `Program.cs`.
|
||||
|
||||
## Контракт для фронта
|
||||
`docs/architecture/2026-09-10-operator-analytics-contract.md` — эндпоинты, query-параметры, JSON-ответы
|
||||
(camelCase), коды ошибок, каталог типов событий аудита (T1), семантика `groupBy`/`key`.
|
||||
|
||||
## Валидация
|
||||
- `dotnet build Deal.sln -v q --nologo` — 0 warnings / 0 errors.
|
||||
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — зелёные.
|
||||
- Новые тесты: `OperatorAnalyticsEndpointsHttpTests` (401, overview, tokens+400 на группировку,
|
||||
activity с actorId/offset, `audit` с actorId/offset), `OperatorAuditEndpointsHelpersTests.NormalizeOffset`.
|
||||
`OperatorAuthHttpHost` расширен фейком `ITokenUsageEventStore` и мэппингом аналитики.
|
||||
|
||||
## Замечания
|
||||
- `overview` без `tenantId`: расход токенов агрегируется по всем тенантам за период
|
||||
(модуль суммирует строки агрегата по тенантам).
|
||||
- Времена в аналитике/аудите — ISO-8601 (как уже принято в `/api/operator/audit`), а не epoch-ms
|
||||
из `/api/cards`: зафиксировано в контрактном документе.
|
||||
@@ -0,0 +1,95 @@
|
||||
# T4 (F1) — Оператор-консоль (фронт)
|
||||
|
||||
> Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`.
|
||||
> Контракт: `docs/architecture/2026-09-10-operator-analytics-contract.md`; формы ручек сверены с кодом
|
||||
> `src/core/Deal.Api/Endpoints/Operator*Endpoints.cs`, `JoinEndpoint.cs` и request/DTO-моделями.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Hash-роутинг без новых зависимостей
|
||||
|
||||
`src/router.js` — крошечный слой над `window.location.hash`:
|
||||
|
||||
| Hash | Экран |
|
||||
|---|---|
|
||||
| `#/` (или пусто) | основное приложение (`views/MainApp.vue`) — как раньше |
|
||||
| `#/operator` / `#/operator/<section>` | консоль оператора (`views/operator/OperatorConsole.vue`) |
|
||||
| `#/join?code=…` | активация инвайта (`views/JoinView.vue`) |
|
||||
|
||||
- Реактивный `route` (`path` + `query`), `routeName`, `operatorSection`; подписка на `hashchange`;
|
||||
`navigate(to)` и `joinLink(code)` (абсолютная ссылка активации для копирования).
|
||||
- `initRouter()` вызывается в `main.js` **до** `createApp().mount()` — первый рендер сразу попадает на нужный
|
||||
экран, без мигания основного приложения.
|
||||
- Прежнее содержимое `App.vue` без изменений вынесено в `views/MainApp.vue`; `App.vue` — тонкая оболочка.
|
||||
Оператор и join грузятся ленивыми чанками (`defineAsyncComponent`). Основное приложение не регрессирует:
|
||||
`#/` работает ровно как раньше (проверено сборкой и сохранением кода 1:1).
|
||||
|
||||
### 2. Экраны оператора
|
||||
|
||||
`views/operator/OperatorConsole.vue` — каркас: проверка сессии (`GET /api/operator/auth/me`), левое меню
|
||||
разделов, переключение по `#/operator/<section>`, выход (`/api/operator/auth/logout`), ссылка в приложение.
|
||||
Глобальные `Toasts` и `ConfirmDialog` (из основного store) подключены на всех экранах консоли, включая вход.
|
||||
|
||||
| Раздел | Ручки (сверено с кодом) |
|
||||
|---|---|
|
||||
| Вход | `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/invites`, `POST /{code}/revoke` (+ копирование `#/join?code=…`) |
|
||||
| Лимиты | `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/analytics/{overview,tokens,activity}` |
|
||||
| Состояние | `GET /api/operator/health` |
|
||||
|
||||
Детали:
|
||||
- **Тенанты**: список (name/status/usersCount/createdAt), создание (name + email владельца; при наличии email
|
||||
показывается одноразовый пароль владельца — только в ответе), карточка тенанта с пользователями, suspend/
|
||||
resume (с подтверждением), impersonate по пользователю.
|
||||
- **Приглашения**: статусы pending/activated/revoked/expired бейджами, создание (email + опциональный тенант),
|
||||
отзыв только pending, копирование ссылки активации.
|
||||
- **Лимиты**: сводка с прогресс-барами расхода, правка бюджета/периода (`PATCH`, форма предзаполняется `GET`).
|
||||
- **Аудит/Действия**: фильтры в одну строку (событие/актор/tenantId/actorId/from/to), пагинация по `offset`
|
||||
(`limit=50`), `total` из ответа, раскрытие `detailJson` (строка JSON → pretty).
|
||||
- **Аналитика**: подразделы обзор (9 плиток метрик), токены (`groupBy=day|tenant|provider|model`, фильтр
|
||||
tenantId, Tailwind-бары + итог), действия (та же лента, что аудит). Внешних chart-библиотек нет.
|
||||
- **Состояние**: `ok`, `core.db`, список сервисов с режимом/доступностью/статусом.
|
||||
|
||||
### 3. Разделение кода
|
||||
|
||||
- **Store-слайс** `src/store/operator.js` — состояние `op` и все действия (thin: fetch → `op` + тост).
|
||||
Импортируется напрямую ленивым чанком (не через `store/index.js`), чтобы код оператора не попадал в
|
||||
основной бандл.
|
||||
- **Примитивы** `src/components/ui/`: `Button`, `TextInput`, `SelectInput`, `Card`, `StatCard`, `Badge`,
|
||||
`DataTable`, `BarList`, `Pagination`.
|
||||
- **Общие блоки оператора** `src/components/operator/`: `SectionLayout`, `AuditFilters`, `AuditTable`
|
||||
(переиспользуются разделами «Аудит» и «Аналитика → Действия» — без дублей).
|
||||
|
||||
## Изменённые/созданные файлы
|
||||
|
||||
- Создано: `src/router.js`; `src/views/MainApp.vue`; `src/views/JoinView.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/operator/{SectionLayout,AuditFilters,AuditTable}.vue`; `src/store/operator.js`.
|
||||
- Изменено: `src/App.vue` (оболочка), `src/main.js` (initRouter), `src/api.js` (несколько 401-обработчиков с
|
||||
путём запроса), `src/store/session.js` (обработчик игнорирует `/api/operator/*` — сессии независимы).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `cd src/frontend && npm run build` — **зелёно** (103 модуля; ленивые чанки `OperatorConsole` ~54 КБ и
|
||||
`JoinView` ~7 КБ).
|
||||
|
||||
## Расхождения с бэком (нужно учесть)
|
||||
|
||||
1. **Impersonation не применяется в браузере.** `POST /{id}/impersonate` отдаёт `sessionToken`, который
|
||||
«используется как значение куки `deal_session`» (комментарий в `OperatorTenantsEndpoints`). Но
|
||||
`AuthEndpoints.SetSessionCookie` ставит куку с `HttpOnly = true`, а completion-ручки (принять токен и
|
||||
выставить куку) нет. JS не может выставить httpOnly-куку → войти под пользователем из UI нельзя.
|
||||
UI вызывает ручку, показывает логин и **токен с кнопкой копирования** и поясняет ограничение.
|
||||
Требуется доработка бэка: либо не-httpOnly кука для impersonation, либо ручка-«completion».
|
||||
2. **`invite_activated` vs `invite_joined`.** В `AuditEvents` есть легаси `invite_activated`, но join пишет
|
||||
`invite_joined`; в каталоге контракта `invite_activated` не значится. В фильтр событий добавлены оба.
|
||||
3. **`GET /api/operator/audit`** отдаёт `{items,total}` без `limit/offset` (в отличие от
|
||||
`analytics/activity`). Пагинация считается по параметрам запроса — расхождение учтено.
|
||||
4. **`PATCH .../limit`**: пустое тело/без полей → `400`; форма всегда отправляет `budget` + `period`.
|
||||
Идемпотентный повтор (те же значения) бэкенд принимает без аудита — поведение корректно.
|
||||
5. Ответы `POST /tenants` включают `ownerEmail`/`initialPassword` **только** при создании с email — пароль
|
||||
показывается один раз в модальном окне.
|
||||
@@ -0,0 +1,40 @@
|
||||
# T5 (F2) — Страница активации инвайта (фронт)
|
||||
|
||||
> Дата: 2026-09-10. Проект НЕ git. Работа — только `src/frontend`.
|
||||
> Контракт ручки сверен с `src/core/Deal.Api/Endpoints/JoinEndpoint.cs` и `JoinRequest.cs`.
|
||||
|
||||
## Что сделано
|
||||
|
||||
`src/views/JoinView.vue` — экран `#/join?code=…` (ленивый чанк, подключён в `App.vue`).
|
||||
|
||||
- Код берётся из query (`route.query.code`), реактивно — переживает `hashchange`.
|
||||
- Форма: **email**, **имя пространства** (опционально), **пароль** (клиентская проверка ≥ 8, до отправки).
|
||||
- Отправка: `POST /api/join` с телом `{ code, email, name: string|null, password }`.
|
||||
Кука не ставится (как и в контракте) — после успеха пользователь входит обычным логином.
|
||||
- **Обработка ошибок контракта** — по фиксированным `detail` из `JoinEndpoint.DetailFor`:
|
||||
- «Приглашение не найдено», «Срок действия приглашения истёк», «Приглашение уже использовано»,
|
||||
«Приглашение отозвано», «Email не совпадает с приглашением», «Этот email уже зарегистрирован»,
|
||||
«Пароль слишком короткий (минимум 8 символов)», «Тенант приглашения не найден»,
|
||||
«Тенант приглашения приостановлен».
|
||||
- Текст `detail` показывается как есть; для известных причин добавляется короткая подсказка (что делать).
|
||||
- Сетевые/неизвестные сбои — общее сообщение.
|
||||
- **Отдельные состояния экрана**:
|
||||
- нет `code` в ссылке → «Ссылка неполная» + возврат ко входу;
|
||||
- успех → «Аккаунт активирован» + кнопка «Перейти ко входу» (`navigate('/')`).
|
||||
- Стиль — как у пользовательского входа (карточка, `radar-grid`, бренд-градиент), переиспользован `Icon`.
|
||||
|
||||
## Файлы
|
||||
|
||||
- Создано: `src/views/JoinView.vue`.
|
||||
- Связано: `src/router.js` (`joinLink` формирует эту ссылку в консоли оператора — раздел «Приглашения»).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `cd src/frontend && npm run build` — **зелёно** (отдельный чанк `JoinView`).
|
||||
|
||||
## Расхождения/замечания
|
||||
|
||||
- Ручка возвращает `400 {detail}` на все отказы активации, включая просроченный инвайт (в коде — «410-семантика»),
|
||||
поэтому фронт не различает 400/410, а опирается на текст `detail`. Это соответствует коду эндпоинта.
|
||||
- `name` обязан быть `null`/пустым для инвайтов с целевым тенантом (имя берётся у тенанта); UI отправляет
|
||||
`name: null`, если поле пустое — бэкенд это принимает (`JoinRequest.Name` опционален).
|
||||
@@ -0,0 +1,27 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md
|
||||
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger.
|
||||
|
||||
## Решение владельца (2026-09-10)
|
||||
Только русский; переключатель языка и второй язык на этом этапе НЕ делались — оставлены в бэклоге
|
||||
(делаем при появлении потребности). Задача — вынести строки в
|
||||
ресурсы (архитектура готова к добавлению языка позже). Визуал/тексты — 1:1.
|
||||
|
||||
## Todos
|
||||
- [x] T1: i18n-ядро (t/locale/словари, фолбэк ru)
|
||||
- [x] T2: Словарь ru (инвентаризация по областям)
|
||||
- [x] T3: Миграция основного приложения на t()
|
||||
- [x] T4: Миграция оператор-консоли и /join
|
||||
- [x] T5: Локализация ошибок/статусов
|
||||
- [x] T6: Проверки (линтер кириллицы вне словарей, build)
|
||||
- [x] T7: Доки и STATUS
|
||||
|
||||
## В бэклоге (делаем при появлении потребности)
|
||||
- Переключатель языка в UI и второй язык.
|
||||
- Форматтеры Intl/плюрализация — вместе с языком.
|
||||
|
||||
## Task status
|
||||
- 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
|
||||
файлах. `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Временные кодимод-скрипты удалены.
|
||||
Переключателя языка в UI нет (по решению владельца). См. task-report.md.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Task report — Дейл, этап 11: локализация (i18n, урезанный объём)
|
||||
|
||||
Дата: 2026-09-10. План: `docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md`.
|
||||
|
||||
## Итог
|
||||
|
||||
Все пользовательские строки фронтенда вынесены из компонентов/вьюх/store в словари-ресурсы.
|
||||
Язык один — русский; **переключателя языка в UI нет и второго языка нет** (решение владельца).
|
||||
Архитектура готова к добавлению языка позже без правок компонентов. Отображаемые тексты — 1:1.
|
||||
|
||||
- `npm run build` — **зелёный** (vite build, `✓ built`).
|
||||
- `npm run lint:i18n` — **зелёный** («кириллических пользовательских строк вне словарей не найдено»).
|
||||
- Словарь: **1039 ключей** в **13 областях**; **1209 ссылок** `t()/$t()` в **65** файлах `src/**/*.{vue,js}`.
|
||||
|
||||
## Структура `src/frontend/src/i18n/`
|
||||
|
||||
```
|
||||
src/i18n/
|
||||
index.js — ядро: t(key, params), реактивный locale (default ru),
|
||||
setLocale(), registerLocale(), availableLocales(), useI18n(),
|
||||
Vue-плагин ($t в шаблонах). Фолбэк: активный язык → ru → сам ключ.
|
||||
errors.js — localizeError(err, fallbackKey): HTTP-статусы/сеть → ключи errors/*,
|
||||
осмысленный {detail} бэка и неизвестное — как есть.
|
||||
locales/
|
||||
ru.js — словарь сообщений (единственный источник текстов).
|
||||
ru.data.js — языковой контент-каталог (валюты, AI-провайдеры, дефолтные
|
||||
промпты, категории/шаблоны промптов), реэкспортируется из src/data.js.
|
||||
```
|
||||
|
||||
Подключение: `main.js` — `createApp(App).use(i18n)`. Алиас Vite `@ → src` (без новых зависимостей)
|
||||
для единого импорта `@/i18n/index.js`.
|
||||
|
||||
## Области ключей (`ru.js`)
|
||||
|
||||
`common/` `nav/` `search/` `cards/` `drawer/` `columns/` `settings/` `channels/` `processing/`
|
||||
`auth/` `operator/` `join/` `errors/` (+ вложенные подразделы). Разбивка по количеству ключей:
|
||||
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.
|
||||
|
||||
## Что сделано по задачам
|
||||
|
||||
- **T1. Ядро i18n** — `src/i18n/index.js` без внешних зависимостей: `t(key, params)` с подстановкой
|
||||
`{name}`, реактивный `locale` (default `ru`), `setLocale()` (готов, UI нет), `registerLocale()`,
|
||||
загрузка словарей, фолбэк на `ru`. Плагин даёт шаблонам `$t(...)`.
|
||||
- **T2. Словарь ru** — инвентаризация строк по областям; значения 1:1 с исходными. Языковой контент
|
||||
из `src/data.js` (валюты, провайдеры, промпты, категории) перенесён в `locales/ru.data.js`.
|
||||
- **T3. Миграция основного приложения** — компоненты, вьюхи и store-слайсы (`store/*.js`) переведены
|
||||
на `t()`/`$t()`; строки в store тоже вынесены.
|
||||
- **T4. Оператор-консоль и `/join`** — все разделы консоли и `JoinView` мигрированы.
|
||||
- **T5. Ошибки/статусы** — `i18n/errors.js` маппит известные HTTP-статусы и сетевые сбои на `errors/*`;
|
||||
точка применения — `errMsg` в `store/core.js`. Неизвестный текст бэка показывается как есть.
|
||||
- **T6. Проверки** — `scripts/i18n-lint.mjs` + `npm run lint:i18n`: падает на кириллицу в пользовательских
|
||||
строках вне `src/i18n/locales/**`; корректно пропускает JS/HTML-комментарии, regex-литералы и строки
|
||||
с директивой `i18n-ignore`. `npm run build` — зелёный.
|
||||
- **T7. Доки** — обновлены `docs/user-guide/Инструкция-пользователя-Дейл.md` (раздел «Язык интерфейса»,
|
||||
без упоминания переключателя) и `docs/technical/Техническая-документация-Дейл.md` (раздел 14: устройство
|
||||
i18n и пошаговая инструкция «как добавить язык позже»). Это единственные правки вне `src/frontend`.
|
||||
|
||||
## Ключевые решения и нюансы
|
||||
|
||||
- **Технические строки не локализованы**: dev-лог в `Icon.vue` и служебные значения помечены
|
||||
`i18n-ignore`; тексты причин отказа бэкенда в `JoinView` оставлены как данные-ключи карты подсказок.
|
||||
- **Плюрализация не вводилась** (отложено): множественные формы по-прежнему выбираются в коде
|
||||
(`... === 1 ? 'файл' : 'файлов'`), но уже через ключи словаря.
|
||||
- **Дубли значений устранены** переназначением ссылок (общие строки — в `common/`).
|
||||
Единственное «дублирование» — `errors.badGateway`/`errors.network` (разные по смыслу статусы).
|
||||
|
||||
## Осталось / отложено (по решению владельца)
|
||||
|
||||
- Переключатель языка в UI и второй язык (en) — по запросу.
|
||||
- Перевод дат/чисел/валют на `Intl` и плюрализация — вместе с будущим языком.
|
||||
- Константы, вычисляемые один раз при загрузке модуля (контент-каталог `ru.data.js`, карты подсказок),
|
||||
держат язык, выбранный на старте; для полноценного «горячего» переключения их потребуется обернуть
|
||||
в `computed`. На текущем этапе (один язык) поведение идентично прежнему.
|
||||
|
||||
## Валидация
|
||||
|
||||
- `cd C:\telbase\src\frontend && npm run build` → `✓ built in ~1.2s` (единственное замечание — предупреждение
|
||||
Vite о размере чанка >500 kB; словарь расширил бандл, на сборку не влияет).
|
||||
- `npm run lint:i18n` → `✓ i18n: кириллических пользовательских строк вне словарей не найдено.`
|
||||
@@ -0,0 +1,238 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger.
|
||||
|
||||
## Автономный заход (без кредов и решений владельца)
|
||||
|
||||
## Todos
|
||||
- [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] C: Перф (i18n-чанк, виртуализация колонок, LRU WTelegram, миграции 1000 схем, lint в test.sh)
|
||||
- [x] C1 (фронт): разбиение бандла + прогрессивный рендер колонок + `lint:i18n` — task-c1-report.md
|
||||
- [x] C2 (бэк/сервисы/скрипты): LRU WTelegram, пакетная миграция схем тенантов, `lint:i18n` в test.sh — task-c2-report.md
|
||||
- [x] D: reclassify-проводка + токены ML — task-d-report.md
|
||||
- [x] E: Остатки этапа 12 (единый `TestPort` без гонки портов, SSE `cards_reclassified`) — task-e-report.md
|
||||
|
||||
## Task status
|
||||
- 2026-09-10: A (метрики) — выполнено. OTel → Prometheus во всех 4 процессах, /metrics на отдельном
|
||||
HTTP/1.1-порту :9464, общий хелпер в Deal.Grpc.Hosting + зеркало в Deal.Api, прикладные метрики
|
||||
(AI/ML токены/вызовы, аудит, очереди, сессии), Prometheus в профиле observability (prod+dev),
|
||||
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,
|
||||
Prometheus scrape — 5/5 UP; стек погашен. Детали — task-a-report.md.
|
||||
- 2026-09-10: фикс регрессии этапа 11 — `PromptDefaultsTests` читали перенесённый `data.js`; переведены
|
||||
на `src/frontend/src/i18n/locales/ru.data.js`. Core-тесты снова **1173/1173 PASS** (тот блок «3 падения» —
|
||||
это и был этот тест, а не пакет C).
|
||||
- 2026-09-10: B (устойчивость/безопасность) — выполнено. Распределённый rate-limit на Postgres
|
||||
(таблица `public.rate_limit_counters`, атомарный upsert) вместо in-memory: HTTP-политики auth/api/global
|
||||
и gRPC-ингресс переведены на store-backed лимитер; `LoginAttemptGuard` — на то же хранилище (async);
|
||||
мгновенный разлогин suspended-сессий (проверка статуса тенанта в `AuthService.ResolveSessionAsync`,
|
||||
включая impersonation); фоновый `DataRetentionScheduler` (purge audit_log по retention 180 дней,
|
||||
сброс накопительных полей tenant_limits прошедших периодов, уборка окон счётчиков). Системная
|
||||
миграция `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),
|
||||
`i18n` (словари локалей), `app`; крупнейший чанк 501.74 kB → **309.02 kB**, предупреждение >500 kB ушло,
|
||||
`chunkSizeWarningLimit` не повышался. Прогрессивный рендер длинных колонок через примитив
|
||||
`useProgressiveList` (`src/composables/progressive.js`): первые 100 карточек + кнопка «Показать ещё»,
|
||||
подключён в `ContainerColumn.vue` (дашборд и «Выбранные»); на малых списках вид 1:1, новых зависимостей нет.
|
||||
`npm run lint:i18n` зелёный, описан в новом `src/frontend/README.md`. В `npm run build` линтер НЕ добавлен.
|
||||
Детали — task-c1-report.md.
|
||||
- 2026-09-10: C — остальные подпункты (LRU WTelegram, миграции на 1000 схем, `lint:i18n` в `scripts/test.sh`)
|
||||
вне фронта; переданы отдельным агентам, каталог src/frontend не затронут.
|
||||
- 2026-09-10: C2 (бэк/сервисы/скрипты) — выполнено. telegram-service: неограниченные словари
|
||||
WTelegram-клиента (`_entityAccessHashes`/`_chatsById`/`_usersById`) переведены на новый
|
||||
`LruCache<TKey,TValue>` (`Caching/LruCache.cs`) с именованными ёмкостями 2000/1000/2000;
|
||||
поведение сохранено (вытеснение → прежние фолбэки). Core: идемпотентность проважининга
|
||||
подтверждена (EF Migrate применяет только неприменённые миграции); добавлен
|
||||
`TenantSchemaMigrationService` — пакетная миграция всех схем реестра (`Parallel.ForEachAsync`,
|
||||
clamp [1..32], прогресс-лог, сбой одной не прерывает остальные) + сводка `TenantMigrationSummary`,
|
||||
операторская ручка `POST /api/operator/maintenance/tenants/migrate`; `TenantBootstrapService`
|
||||
переведён на тот же сервис (параллельно + логи); bootstrap не сломан. `scripts/test.sh` теперь
|
||||
после core-тестов гоняет `npm run lint:i18n` (нет npm — мягкий пропуск). Telegram-тесты
|
||||
**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 не поднимался,
|
||||
хвостов нет. Детали — task-c2-report.md.
|
||||
- 2026-09-10: D (ИИ/ML без кредов) — выполнено. `POST /api/cards/reclassify` (batch inbox/ids) и новый
|
||||
`POST /api/cards/{id}/reclassify` — реальная переклассификация через тот же конвейер, что и воркер
|
||||
(ИИ-фильтр → классификация `IAiClassifier` → сборка `CardComposer`/ContainerAccepts → обновление карточки
|
||||
→ обучающие сигналы ML), без создания новой карточки. Фолбэк без/при недоступности ИИ — локальный
|
||||
детерминированный разбор (`LocalFieldsParser`+`AiCardMapper`), без кредов не падает. Одно обновление
|
||||
карточки — новый порт `ICardStore.ApplyReclassificationAsync` (+ `CardReclassificationDto`); спам/фильтр
|
||||
→ корзина через `CardsService.TrashCardAsync(teach:false)` + сигнал «спам» 0.4. Логика обучения ML вынесена
|
||||
в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация, без дублей); вес `AiPushWeight`
|
||||
централизован в `MlLearningLabels`. Одно переклассификация за раз — `ReclassifyGate` (single-flight, busy).
|
||||
Ответ расширен совместимо: `started/busy/attempted` + `reclassified/moved/kept/trashed/skipped/usedAi/reason`.
|
||||
Аудит `card_reclassified`. Контрактный документ §reclassify обновлён. Учёт токенов ML проверен: покрыт
|
||||
без изменений (GrpcAiClassifier/TokenUsageRecorder — ИИ-путь, включая reclassify; GrpcMlClient —
|
||||
kind=ml + `deal.ml.*`; Local-адаптеры по решению этапа 10 не пишут). Build Deal.sln 0/0;
|
||||
core-тесты **1203/1203 PASS** (+14). Docker не поднимался, хвостов нет. Детали — task-d-report.md.
|
||||
- 2026-09-10: E (закрытие остатков после этапа 12) — выполнено. (1) 10 копий приватного `FreeTcpPort()` в
|
||||
`tests/Deal.Tests.Unit` заменены единым `TestPort.Allocate()` (`TestPort.cs`): выданные в процессе порты не
|
||||
выдаются повторно, при коллизии биндинг порта 0 повторяется — редкий `AddressInUseException` при
|
||||
параллельном прогоне устранён, поведение тестов не изменилось. (2) Добавлено SSE-событие `cards_reclassified`
|
||||
`{reclassified, moved}`: публикуется из обоих reclassify-эндпоинтов после успешного прохода
|
||||
(`started && reclassified > 0`), фронт слушает его в `api.js` и мягко перечитывает доску + статус ML в
|
||||
`lifecycle.js` (мёртвая ветка `leads_reclassified` переименована). Контракт §SSE/reclassify обновлён.
|
||||
Build Deal.sln 0/0; core-тесты **1203/1203 PASS**; `npm run build` и `npm run lint:i18n` зелёные;
|
||||
`dotnet build-server shutdown` выполнен, хвостов нет. Детали — task-e-report.md.
|
||||
|
||||
## Остатки
|
||||
|
||||
- Закрыты в задаче E: гонка `FreeTcpPort()` в тест-харнессе core и отсутствие SSE-события о завершении
|
||||
переклассификации. Детали — task-e-report.md.
|
||||
- Двойная перезагрузка доски у инициатора batch-reclassify (ответ эндпоинта + SSE): осознанно оставлено ради
|
||||
сохранения текущего поведения и работы при отвале SSE.
|
||||
- Мёртвые ветки фронта `boards_changed`/`pipeline_stats`: core этих SSE-событий не публикует (наследие этапа 3).
|
||||
- Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный).
|
||||
|
||||
## Остатки после этапа 12 — закрыто координатором (2026-09-10)
|
||||
|
||||
- **Доки под этап 12**: `docs/api/api-map.md` (real-reclassify + его форма ответа, `POST /api/operator/maintenance/tenants/migrate`,
|
||||
удалены упоминания демо/`DEAL_DEMO`, абзац про rate-limit/retention); `docs/technical` (rate-limit на
|
||||
`rate_limit_counters`, retention, metrics/алерты, скрипты нагрузки, скан уязвимостей).
|
||||
- **Prometheus alert rules**: `deploy/observability/prometheus-rules.yml` (6 правил), подключены в
|
||||
`prometheus.yml` и смонтированы в compose prod+dev; `promtool`/`compose config` — OK.
|
||||
- **Мусор**: удалён `deploy/observability/promtail.yml;C`; следов `*.orig/*.bak/*;*` нет.
|
||||
- **Нагрузка**: `scripts/loadtest/` (bash+curl и k6-вариант) + README.
|
||||
- **Скан уязвимостей (read-only)**: core `dotnet list package --vulnerable --include-transitive` — 0 уязвимых
|
||||
пакетов (12 проектов); frontend `npm audit` — 0.
|
||||
|
||||
## Три базовых документа (сопровождение)
|
||||
|
||||
Ведятся постоянно по договорённости:
|
||||
- **ТЗ** `docs/spec/ТЗ-дейл-новая-архитектура.md` (версия 1.0, этапы 0–12);
|
||||
- **Инструкция пользователя** `docs/user-guide/Инструкция-пользователя-Дейл.md` (версия 1.3, этапы 0–12);
|
||||
- **Техническая документация** `docs/technical/Техническая-документация-Дейл.md` (версия 2.0, этапы 0–12).
|
||||
Плюс поддерживаются: `docs/api/api-map.md`, `docs/superpowers/STATUS.md`, контракты этапов 9–10 и планы/ledgers.
|
||||
|
||||
## Добивка по ТЗ — внешний вид (§8.12), 2026-09-10
|
||||
|
||||
Аудит ТЗ нашёл единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: не было раздела
|
||||
оформления, тема только тёмная. Закрыто во фронтенде (`src/frontend`, без новых зависимостей):
|
||||
- **Светлая тема через токены.** Тёмная палитра — дефолт в `@theme` (`style.css`, вид 1:1). Светлая —
|
||||
блок `:root[data-theme="light"]` с теми же токенами (+ тени, скроллбар, линии сетки входа, `.tgmd`,
|
||||
`color-scheme`). Компоненты не переписывались.
|
||||
- **Полупрозрачные слои** `bg-white/N` / `border-white/N`: в светлой теме токен `--color-white`
|
||||
указывает на тёмный оттенок (мягкие серые подложки/границы); литеральные `text-white`/`bg-white`
|
||||
(текст на градиенте, QR, ползунок) восстановлены отдельными правилами; добавлены `--color-on-brand`,
|
||||
`--color-on-warn` для текста поверх сплошной заливки. `accent-[#8b8ff8]` → `accent-brand` (4 места),
|
||||
убраны инлайн `color-scheme: dark` (теперь наследуется от `html`).
|
||||
- **Раздел «Внешний вид»** в настройках (вкладка `appearance` рядом с «Уведомлениями»), три варианта:
|
||||
Тёмная (дефолт) / Светлая / Системная; мгновенное переключение и тост. Строки — в словаре (`settings.tema-*`).
|
||||
- **Сохранение и отсутствие мигания.** `composables/theme.js` (`dark|light|system`, ключ
|
||||
`localStorage.leadradar_theme`), инлайн-скрипт в `index.html` применяет тему до первого рендера;
|
||||
режим `system` слушает `prefers-color-scheme`.
|
||||
- **Проверка:** `npm run build` — зелёный, `npm run lint:i18n` — зелёный; тёмный вид не менялся.
|
||||
- Техдок дополнен разделом §15. Детали — `task-tz-theme-report.md`.
|
||||
|
||||
## Добивка по ТЗ — бэкенд core (ML/исключения/группы/health/suspicious), 2026-09-10
|
||||
|
||||
Закрыты частично закрытые пункты аудита ТЗ (только `src/core`, тесты + отчёт):
|
||||
|
||||
1. **§8 ML — проверка на канале/сообщении (`POST /api/ml/candidates|apply`).** Заглушки заменены
|
||||
рабочим `MlReviewService`: кандидаты объединяются из очереди/отсева/карточек по (dialogId, msgId),
|
||||
с текущим вердиктом и мнением ML; `apply` применяет ручное решение (`skip`/`spam`/`board:<id>`)
|
||||
через существующие сервисы (`CardsService`/`PipelineProcessingService`/`IMlClient.PushAsync`).
|
||||
2. **§5.14/§8 глобальные исключения (стоп до ML/ИИ).** Настройки `excludeKeywords/excludeLocations/
|
||||
excludeTypes/excludeBudgetFrom/excludeBudgetTo` (каталог/дефолты/PATCH/GET), чистый
|
||||
`GlobalExclusionRules`, вызов на стоп-этапе воркера; причина отсева называет исключение.
|
||||
3. **§6.3 группы фильтров колонки.** `ContainerRulesDto`/`IContainerRules` расширены группами
|
||||
`levels/locations/types/prices` (обратная совместимость JSON сохранена); матчинг, `matchHits`,
|
||||
`describe` и `RulesJson`-сериализация обновлены.
|
||||
4. **§10.2 глубины очередей в health.** Общий `RuntimeDepthsCollector` (переиспользован и метриками):
|
||||
`queues:{pipeline,mlOutbox}` и `sessions:{active}` в `GET /api/operator/health`.
|
||||
5. **§10.5 подозрительная активность.** `SuspiciousActivityService` (пороговые правила по аудиту) +
|
||||
`GET /api/operator/analytics/suspicious`.
|
||||
|
||||
- **Проверка:** `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`.
|
||||
|
||||
## Добивка по ТЗ — фронтенд (UI-части), 2026-09-10
|
||||
|
||||
Закрыты UI-части пунктов, чьи бэкенд-контракты закрыты выше (только `src/frontend`, без новых зависимостей):
|
||||
|
||||
1. **«Открыть исходник» на карточке (§6.6).** `Card.vue`: рядом с комментарием/корзиной/переносом —
|
||||
быстрая кнопка-ссылка на исходное сообщение (тот же `tgSourceUrl`: `channel.handle`/`sourceDialogId`+
|
||||
`sourceMsgId`); нет данных — кнопки нет. Только режим дашборда.
|
||||
2. **Глобальные исключения в настройках (§5.14/§8).** Вкладка «Фильтры входящих» (`StopTab.vue`): блок
|
||||
«Глобальные исключения» — теги ключевых слов и локаций, чипы типов (вакансия/фриланс/объявление),
|
||||
диапазон бюджета. Персист — PATCH `/api/settings` (`excludeKeywords/excludeLocations/excludeTypes/
|
||||
excludeBudgetFrom/excludeBudgetTo`), поля в `core.js`/`settings.js`.
|
||||
3. **Группы фильтров колонки (§6.3).** `BoardRulesDialog.vue`: добавлены `levels`, `locations` (группы-теги),
|
||||
`types` (чипы vacancy/freelance/announcement) и `prices` (второй диапазон, общая разметка с `budget`).
|
||||
Сохранение — прежним `saveBoardForm`; старые `rules` без новых групп разбираются как раньше.
|
||||
Заодно исправлен сломанный счётчик значений группы (выводил литерал шаблона вместо слова).
|
||||
4. **Оператор-консоль (§10.2/§10.5).** «Состояние» (`HealthSection.vue`): карточка «Очереди и сессии»
|
||||
(`queues.pipeline`, `queues.mlOutbox`, `sessions.active`). «Аналитика» (`AnalyticsSection.vue`):
|
||||
подраздел «Подозрительная активность» (`GET /api/operator/analytics/suspicious`) со списком находок.
|
||||
|
||||
- **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; светлая тема не ломалась
|
||||
(новые элементы — на существующих токенах). Внешние процессы не запускались (`:5173` свободен).
|
||||
- Детали и найденные расхождения — `task-tz-frontend-report.md`.
|
||||
|
||||
## Telegram-ключи: вариант A (глобальные ключи оператора, ТЗ §4.1/§8.1), 2026-09-10
|
||||
|
||||
Решение владельца (вариант A): `api_id`/`api_hash` задаёт **оператор глобально**, тенант — только
|
||||
подключает аккаунт. Только `src/core` + комментарии proto (без фронта/telegram-service/deploy).
|
||||
|
||||
1. **Глобальное хранилище (public).** Новая системная таблица `public.global_settings`
|
||||
(`Key` PK, `Value` text, `UpdatedAt`): сущность `GlobalSettingEntity`, конфигурация
|
||||
`GlobalSettingConfiguration`, `DbSet`/`ApplyConfiguration` в `DealDbContext`, EF-адаптер
|
||||
`GlobalSettingsStore`, порт `IGlobalSettingsStore` + каталог `GlobalSettingsKeys` (`telegramKeys`)
|
||||
в модуле Settings, регистрация в `AddDealPersistence`. Системная миграция
|
||||
`Migrations/20260910194443_GlobalSettings.cs` (`--context DealDbContext`).
|
||||
2. **Операторские ручки.** `GET/PUT /api/operator/settings/telegram-keys` (`OperatorSettingsEndpoints`):
|
||||
GET — маска (`apiId` открыт, `apiHash` маска, `keysSet`), PUT `{apiId, apiHash}` — валидация
|
||||
(api_id 5..9 цифр, api_hash непустой), шифрование `apiHash` (`enc:`), аудит `telegram_keys_changed`
|
||||
(без секретов). 401 без операторской сессии, ошибки `{detail}`.
|
||||
3. **Ядро читает глобальные ключи.** `TelegramKeysService` переведён на `IGlobalSettingsStore`
|
||||
(ключ `telegramKeys`); `TgStatusService`/`start-phone`/`start-qr` работают от глобальных ключей.
|
||||
Нет ключей — `400 {detail} «Ключи Telegram не заданы оператором»`, статус отдаёт `keysSet=false`.
|
||||
4. **Настройки тенанта.** `tgKeys` удалён из `PublicForms`/`TgKeysPublicDto`/`SettingKind`/
|
||||
`SettingsDefaults`/`SettingsKeys`/`PatchSecrets` и `PublicSettingsDto` (миграция данных не нужна —
|
||||
данные тестовые). Вкладка/статус Telegram у тенанта остаются (подключение аккаунта).
|
||||
5. **Proto-комментарии** `src/contracts/telegram.proto`: tgKeys тенанта → глобальные ключи оператора.
|
||||
6. **Контракт для фронта** — в `docs/architecture/2026-09-10-operator-analytics-contract.md`
|
||||
(раздел «Операторские настройки: глобальные ключи Telegram», + событие `telegram_keys_changed`).
|
||||
|
||||
- **Проверка:** `dotnet build Deal.sln` — 0/0; core-тесты **1271/1271** (было 1245).
|
||||
Миграция сгенерирована; применение к dev-Postgres отложено (Postgres :5433 не поднят в этой сессии).
|
||||
- Детали — `task-tgkeys-report.md`.
|
||||
|
||||
## Telegram-ключи: фронтенд (вариант A), 2026-09-10
|
||||
|
||||
Закрыты UI-части варианта A (только `src/frontend`, без новых зависимостей):
|
||||
|
||||
1. **Оператор-консоль — раздел «Telegram».** Новый `views/operator/TelegramSection.vue` (пункт меню
|
||||
после «Состояния»): маскированный снимок глобальных ключей (`keysSet`, `apiId`, маска `apiHash`),
|
||||
форма `api_id`/`api_hash` с клиентской валидацией (5–9 цифр / непустой секрет), `PUT`
|
||||
`/api/operator/settings/telegram-keys`; после сохранения секрет не удерживается. В `store/operator.js` —
|
||||
`op.tgKeys` + `loadTelegramKeys`/`saveTelegramKeys`; в `api.js` добавлен `api.put`; в каталог
|
||||
`AUDIT_EVENT_TYPES` — `telegram_keys_changed`.
|
||||
2. **Настройки тенанта — ключи убраны.** Из `settings/TelegramTab.vue` удалён блок api_id/api_hash
|
||||
(осталось подключение аккаунта/статус/QR); при `keysSet:false` — предупреждение, что подключение
|
||||
недоступно, пока оператор не задаст ключи. Из `store/settings.js` (обработка `tgKeys` и
|
||||
`saveSettings`) и `store/core.js` (`state.apiId`/`apiHash`) удалены мёртвые поля; `tgKeysSet`
|
||||
оставлен (приходит из `/api/tg/status`). Строки — в словаре, мёртвые ключи вычищены.
|
||||
|
||||
- **Проверка:** `npm run build` — зелёный; `npm run lint:i18n` — зелёный; тёмная/светлая темы не
|
||||
ломались; `:5173` свободен (процессы не запускались).
|
||||
- Детали и найденные расхождения — `task-tgkeys-frontend-report.md`.
|
||||
|
||||
## Telegram-ключи: частичное обновление + применение миграции + сквозная проверка, 2026-09-10
|
||||
|
||||
1. **Частичное обновление PUT.** `PUT /api/operator/settings/telegram-keys` принимает `{apiId?, apiHash?}`:
|
||||
непереданное поле (`null`) сохраняет текущее значение, явное (`""`/маска/`enc:`) валидируется.
|
||||
Если ключей ещё нет — оба обязательны (`400 {detail} «Ключи ещё не заданы — укажите и api_id, и api_hash»`);
|
||||
ни одного поля — `400 «Укажите api_id и api_hash»`. Правило слияния — в `OperatorSettingsEndpoints`
|
||||
(`GetAsync` текущих + `SaveAsync`); `TelegramKeysRequest`/`TelegramKeysService.SaveAsync` — без смены
|
||||
строгой валидации. Контракт обновлён (`docs/architecture/2026-09-10-operator-analytics-contract.md`),
|
||||
тесты +4 (только apiId, только apiHash, частичное без ключей, пустое тело).
|
||||
2. **Миграция `GlobalSettings` применена** к dev-Postgres (:5433, контейнер `deal-postgres`):
|
||||
`dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api`
|
||||
→ `Applying migration '20260910194443_GlobalSettings'. Done.` Таблица `public.global_settings`
|
||||
(`Key` PK, `Value` text, `UpdatedAt` timestamptz) подтверждена `\d global_settings`. Postgres погашен.
|
||||
3. **Сквозная проверка.** Build 0 warnings/0 errors и тесты PASS по всем 4 решениям: core **1275/1275**
|
||||
(было 1271), telegram **125/125**, ai **52/52**, ml **38/38**. Хвостов нет (`docker ps` без `deal-*`,
|
||||
порты 5433/5080/5082/5101–5103 свободны, `dotnet build-server shutdown`).
|
||||
|
||||
- Детали — `task-final-report.md`.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Task A — Метрики (OpenTelemetry → Prometheus + Grafana). Отчёт
|
||||
|
||||
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет A).
|
||||
Статус: **выполнено**. Все 4 процесса отдают `/metrics` в формате Prometheus, Prometheus скрейпит
|
||||
их в профиле `observability`, Grafana провижинит datasource и дашборд метрик.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Метрики во всех 4 процессах
|
||||
- Пакеты OTel (1.17.x) добавлены в `src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj`
|
||||
(для telegram/ai/ml) и в `src/core/Deal.Api/Deal.Api.csproj` (ядро):
|
||||
`OpenTelemetry.Extensions.Hosting` 1.17.0, `OpenTelemetry.Exporter.Prometheus.AspNetCore`
|
||||
1.17.0-beta.1, `OpenTelemetry.Instrumentation.AspNetCore` 1.17.0,
|
||||
`OpenTelemetry.Instrumentation.Http` 1.17.0, `OpenTelemetry.Instrumentation.GrpcNetClient`
|
||||
1.17.0-beta.1.
|
||||
- ⚠ Важно: Prometheus-экспортёр и GrpcNetClient выпускаются только pre-release-линией (стабильных
|
||||
1.17.0 нет) — версии зафиксированы на `-beta.1`; `GrpcNetClient` даёт трейс-инструментацию
|
||||
исходящих gRPC (метрик в пакете нет), оставлен как задел под будущий трейсинг.
|
||||
- Общий хелпер сервисов — `src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs`:
|
||||
`AddDealMetrics(builder, metricsPort)` (Kestrel-эндпоинт метрик + OTel + экспортёр) и
|
||||
`MapDealMetrics(app)` (`/metrics`). Вызов — 2 строки из `Program.cs` каждого сервиса через
|
||||
`configureBuilder`-хук (тесты хостов не затрагиваются — метрики в них не поднимаются).
|
||||
- Ядро: зеркальный хелпер `src/core/Deal.Api/Observability/DealMetricsHosting.cs` (ядро не ссылается
|
||||
на обвязку gRPC-сервисов — по конвенции проекта держит свою копию). Вызов — из `Program.cs`.
|
||||
- **Эндпоинт `/metrics` — отдельный HTTP/1.1 Kestrel-эндпоинт :9464** у всех 4 процессов
|
||||
(env `METRICS_PORT`). Выбор обоснован: gRPC-порты :5101–:5103/:5082 слушают только HTTP/2, а scrape
|
||||
Prometheus — обычный GET по HTTP/1.1; вынос на отдельный порт не меняет протокол gRPC-эндпоинтов.
|
||||
Порт наружу не публикуется (scrape — внутри compose-сети).
|
||||
|
||||
### 2. Прикладные метрики (meter `Deal`, имена `deal.*`, метки низкокардинальные)
|
||||
- Инструменты — `src/core/Deal.SharedKernel/Observability/DealMetrics.cs` (static Meter — общий для
|
||||
точек инкремента в разных модулях). Публичных меток с tenantId/userId/cardId **нет**.
|
||||
- Инкремент там же, где уже пишутся данные (без дублирования логики):
|
||||
- `TokenUsageRecorder.AddAsync` → `deal.ai.calls` + `deal.ai.tokens{type=prompt|completion}`
|
||||
(та же точка, что `token_usage_events` kind=ai);
|
||||
- `TokenUsageRecorder.AddEstimatedAsync` → `deal.ml.calls` + `deal.ml.tokens` (kind=ml);
|
||||
- `AuditService.AppendAsync` → `deal.audit.events{event,actor}` (по типам/акторам).
|
||||
- Gauge-метрики собирает фоновый `src/core/Deal.Api/Observability/DealMetricsCollector.cs`
|
||||
(IHostedService, период 15 с; эталон StorageTickScheduler):
|
||||
- `deal.pipeline.queue.depth` — сумма по тенантам из существующего
|
||||
`PipelineProcessingService.QueueCountsAsync` (new+filtered);
|
||||
- `deal.ml.outbox.depth` — сумма из `IMlLearningStore.CountOutboxAsync` (count(MlOutbox));
|
||||
- `deal.sessions.active` — count(public.sessions) + count(public.operator_sessions) с
|
||||
непросроченным ExpiresAt.
|
||||
- Значения публикуются в `DealMetrics`; callback ObservableGauge отдаёт их Prometheus при scrape.
|
||||
|
||||
### 3. Scrape / Prometheus / Grafana
|
||||
- `deploy/observability/prometheus.yml` — job `deal` с таргетами
|
||||
`core:9464 / telegram-service:9464 / ai-service:9464 / ml-service:9464` (target-метка `service`) +
|
||||
само-мониторинг. Ключевое: `global.metric_name_validation_scheme: legacy` — Prometheus 3 иначе
|
||||
запрашивает UTF-8-схему и сохраняет метрики с точками (`deal.sessions.active`), ломая привычные
|
||||
имена; legacy-схема даёт стабильные `deal_sessions_active`, `http_server_request_duration_seconds_*`.
|
||||
- `deploy/compose.prod.yml` — сервис `prometheus` (`prom/prometheus:v3.5.0`) в профиле
|
||||
`observability`: конфиг + volume `deal_prometheus_data`, retention 15 суток, UI только
|
||||
`127.0.0.1:9090`; Grafana `depends_on` prometheus; шапка/volume-секция обновлены.
|
||||
- `deploy/compose.dev.yml` — тот же сервис `prometheus` (профиль `observability`, UI `:9090`) для
|
||||
локальной проверки.
|
||||
- Grafana-провижининг:
|
||||
- `deploy/observability/grafana/provisioning/datasources/datasources.yml` — добавлен datasource
|
||||
Prometheus (uid `prometheus`, `http://prometheus:9090`; Loki остаётся default);
|
||||
- `deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json` (uid `deal-metrics`) —
|
||||
RPS/p95/5xx по сервисам, токены и вызовы AI/ML, глубины очередей, активные сессии, топ событий
|
||||
аудита.
|
||||
|
||||
### 4. Документация
|
||||
- `docs/technical/Техническая-документация-Дейл.md` — §7 дополнен подразделом «Метрики
|
||||
(Prometheus + Grafana)»: как поднять профиль, порты (Grafana 3001, Prometheus 9090, /metrics 9464),
|
||||
перечень метрик, где смотреть; обновлены таблица стека, §8 (профиль observability), сводки.
|
||||
|
||||
## Порты / эндпоинты
|
||||
| Процесс | gRPC/HTTP | Метрики |
|
||||
|---|---|---|
|
||||
| core | 5080 HTTP, 5082 gRPC | `:9464/metrics` (HTTP/1.1) |
|
||||
| telegram-service | 5101 gRPC | `:9464/metrics` |
|
||||
| ai-service | 5102 gRPC | `:9464/metrics` |
|
||||
| ml-service | 5103 gRPC | `:9464/metrics` |
|
||||
| prometheus | — | UI `127.0.0.1:9090` (prod), `:9090` (dev) |
|
||||
| grafana | — | UI `127.0.0.1:3001` |
|
||||
|
||||
Порт метрик переопределяется env `METRICS_PORT` (при запуске нескольких процессов на хосте без compose
|
||||
нужны разные значения).
|
||||
|
||||
## Как проверял
|
||||
- **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.
|
||||
- **Тесты**: ai/ml/telegram — PASS полностью. core — PASS, кроме 3 предсуществующих падений
|
||||
`PromptDefaultsTests` (тесты читают `export const DEFAULT_AI_PROMPT` из `src/frontend/src/data.js`, а
|
||||
фронт переписан пакетом C: промпты вынесены в `src/frontend/src/i18n/locales/ru.data.js`). К пакету A
|
||||
и моим изменениям отношения не имеет; фронт не трогал.
|
||||
- **Конфиги**: `docker compose ... --profile observability config --quiet` — prod rc=0 (с обязательными
|
||||
env), dev rc=0. YAML (`prometheus.yml`, datasources, dashboards) и JSON дашборда проверены парсером;
|
||||
`promtool check config` — SUCCESS.
|
||||
- **Живой прогон (dev-стек, Docker)**: `docker compose -f deploy/compose.dev.yml up -d --build` →
|
||||
`/metrics` всех 4 процессов отдают валидный Prometheus-формат (у core видны gauge
|
||||
`deal_sessions_active`, `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` и
|
||||
`http_server_request_duration_seconds_count`) → поднят `prometheus` (профиль observability) →
|
||||
`/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 ps` без deal-контейнеров, `dotnet build-server shutdown`. Хвостов нет.
|
||||
|
||||
## Осталось / замечания
|
||||
- Токен/вызов-счётчики AI/ML появляются в `/metrics` только после первого инкремента (стандартное
|
||||
поведение экспортёра) — живого платного ИИ-вызова без кредов нет, поэтому в приёмке их не было; код
|
||||
инкремента — в тех же точках, что `token_usage_events`.
|
||||
- Отдельный Prometheus-конфиг для dev не заводил — общий `deploy/observability/prometheus.yml`
|
||||
(имена сервисов совпадают).
|
||||
- Живая проверка Grafana (загрузка дашборда UI) не гонялась — JSON/провижининг валидны; помечаю как
|
||||
⚠ Manual.
|
||||
- Предсуществующие падения `PromptDefaultsTests` — за пакетом C/фронтом, не чинил (вне зоны задачи).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Task B — Устойчивость/безопасность (распределённый rate-limit, мгновенный разлогин, авто-очистки). Отчёт
|
||||
|
||||
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (пакет B).
|
||||
Статус: **выполнено**. Итог: `dotnet build Deal.sln` — 0/0; core-тесты **1184/1184 PASS** (+11 новых).
|
||||
Все живое-прогоны Postgres погашены (контейнеров `deal-*` не осталось; build-server остановлен).
|
||||
|
||||
## B1. Распределённый rate-limit (хранилище на Postgres)
|
||||
|
||||
In-memory `FixedWindowRateLimiter` заменён на store-backed лимитер; состояние счётчиков — в новой
|
||||
системной таблице `public.rate_limit_counters`, общей для всех инстансов core.
|
||||
|
||||
- **Порт** `Deal.Modules.Tenants.Application.IRateLimitCounterStore`: `IncrementAsync` (окно+приращение →
|
||||
значение), `GetCountAsync`, `ResetAsync`, `DeleteExpiredAsync` (TTL-уборка по `ExpiresAt`).
|
||||
- **Сущность/конфигурация** `RateLimitCounterEntity` / `RateLimitCounterConfiguration` (public, PK `Key`,
|
||||
индекс по `ExpiresAt`), `DealDbContext.RateLimitCounters`.
|
||||
- **EF-адаптер** `RateLimitCounterStore` (Deal.Infrastructure): на Postgres инкремент — один атомарный
|
||||
`INSERT … ON CONFLICT ("Key") DO UPDATE … RETURNING "Count"` (raw-команда: EF запрещает композицию по
|
||||
не-SELECT SQL, поэтому не `SqlQuery`); смена `WindowStart` сбрасывает счётчик (семантика фиксированного
|
||||
окна). InMemory-ветка (тесты) — read-modify-write (эталон `TenantLimitStore`). Удаление — `ExecuteDelete`
|
||||
на Npgsql, выгрузка+`RemoveRange` на InMemory.
|
||||
- **Store-backed лимитер** `Deal.Api.Http.StoreBackedFixedWindowRateLimiter` (подкласс `RateLimiter`):
|
||||
окно выровнено по границам длины (одинаковые окна у всех инстансов/ключей — иначе распределённый лимит
|
||||
несогласован), разрешено ровно `PermitLimit`; хранилище (scoped) резолвится в собственном DI-scope на
|
||||
каждое приобретение через `IServiceScopeFactory`; `IdleDuration` = длина окна (партиции вытесняются).
|
||||
- **Проводка**: `RateLimitPolicies` — партиции `http:{policy}:{ip|tenant}` (`auth`, `api`, глобальный
|
||||
лимитер — своё пространство `http:global:*`, чтобы именованная и глобальная политики не схлопывали
|
||||
счётчики); `IngressRateLimitInterceptor.CreateLimiter(IServiceScopeFactory, permit)` — партиции
|
||||
`grpc:ingress:{tenant-id}`. Пороги/окна не менялись: auth 10/мин·IP, api/global 600/мин·тенант|IP,
|
||||
gRPC 600/мин·tenant-id, окно 1 минута, ответ 429/RESOURCE_EXHAUSTED с прежними текстами.
|
||||
- **Уборка** старых окон — фоновая (`DataRetentionScheduler`, см. B4) по TTL `ExpiresAt`.
|
||||
|
||||
## B2. Учёт попыток входа на Postgres
|
||||
|
||||
`Deal.Api.Http.LoginAttemptGuard` переведён с in-memory-`Dictionary` на `IRateLimitCounterStore`
|
||||
(ключ `login:{ip}|{login}`), API стал асинхронным (`IsBlockedAsync`/`RecordFailureAsync`/`ResetAsync`);
|
||||
эндпоинты `/api/auth/login` и `/api/operator/auth/login` обновлены. Семантика сохранена 1:1: окно
|
||||
`LoginAttemptWindowMin` (15 мин), порог `LoginAttemptsMax` (5), сброс при успехе, `Enabled=false` — no-op,
|
||||
текст 429 прежний. Гвард зарегистрирован **scoped** (зависит от scoped-хранилища).
|
||||
|
||||
## B3. Мгновенный разлогин suspended-сессий
|
||||
|
||||
Выбран наименее рискованный вариант — **проверка статуса тенанта в резолве сессии** (без мутации строк
|
||||
сессий и без изменения проверки 403 на входе):
|
||||
|
||||
- `AuthService.ResolveSessionAsync` после разрешения пользователя читает тенанта (`ITenantRepository`)
|
||||
и возвращает null, если статус `suspended`. Сессия проверяется на каждом запросе (SessionMiddleware),
|
||||
поэтому при suspend активные сессии перестают действовать **немедленно**, а не доживают до expiry.
|
||||
- Флаг состояния не храним: при resume тот же токен вновь разрешается (сессия не удаляется).
|
||||
- Impersonation учитывается автоматически — это та же tenant-сессия (маркер оператора).
|
||||
- Обновлены remarks `TenantStatuses`/`AuthService` (уточнён Ruling 10(5)).
|
||||
|
||||
## B4. Авто-очистки
|
||||
|
||||
- **audit_log**: `IAuditLogStore.PurgeOlderThanAsync(cutoff)` (ExecuteDelete на Npgsql). Retention —
|
||||
настройка `DataRetention:AuditRetentionDays` (дефолт/константа 180 дней; `Enabled` управляет циклом).
|
||||
- **tenant_limits**: отдельной истории периодов в схеме нет (одна строка на тенанта с ленивым reset),
|
||||
поэтому реализован `ITenantLimitStore.ResetExpiredPeriodsAsync(now)` — обнуляет накопительные поля
|
||||
(`UsedTokens`/`Warned80`/`NotifiedExhausted`) строк с завершившимся периодом и сдвигает `PeriodStart=now`;
|
||||
идемпотентно. Отдельную таблицу истории не вводили (её нет — «чистить нечего», кроме накоплений).
|
||||
- **счётчики**: `IRateLimitCounterStore.DeleteExpiredAsync(now)` убирает окна с истёкшим `ExpiresAt`.
|
||||
- **Цикл**: `Deal.Api.Hosting.DataRetentionScheduler` (IHostedService, эталон `DealMetricsCollector`):
|
||||
первый проход через 60 с, далее раз в 24 ч; один scope на проход, in-flight guard, graceful stop,
|
||||
ошибки логируются и не валят хост. Конфигурация — `Deal.Api.Configuration.DataRetentionOptions`
|
||||
(секция `DataRetention`, добавлена в `appsettings.json`), регистрация в `Program.cs`.
|
||||
|
||||
## Миграции
|
||||
|
||||
Системная (public) миграция `20260910172545_RateLimitCounters`
|
||||
(`Deal.Infrastructure/Migrations/`, `--context DealDbContext`): таблица `public.rate_limit_counters`
|
||||
(`Key` text PK, `WindowStart`/`ExpiresAt` timestamptz, `Count` int) + индекс `IX_rate_limit_counters_ExpiresAt`.
|
||||
Применена и проверена на dev-Postgres (:5433): `dotnet ef database update --context DealDbContext` — OK.
|
||||
|
||||
## Тесты (+11, все зелёные)
|
||||
|
||||
- `LoginAttemptGuardTests` — переписаны на async + `FakeRateLimitCounterStore` (те же 8 сценариев).
|
||||
- `RateLimitCounterStoreTests` (6) — окно/накопление/сброс окна/чтение/Reset/DeleteExpired (InMemory-ветка).
|
||||
- `DataRetentionSchedulerTests` (2) — purge аудита, сброс лимитов, уборка счётчиков; идемпотентность.
|
||||
- `AuthServiceTests` +1 — suspend → сессия не разрешается; resume → разрешается.
|
||||
- `AuditLogStoreTests` +1 — purge по границе; `TenantLimitStoreTests` +1 — сброс только истёкших периодов.
|
||||
- Обновлены: `FakeAuditLogStore`/`FakeTenantLimitStore` (новые методы), `FakeRateLimitCounterStore` (новый),
|
||||
`OperatorAuthHttpHost`/`RateLimitHttpTests`/`TelegramIngressTestHost` (регистрация фейк-хранилища,
|
||||
новый `CreateLimiter`), `AuditServiceTests` (ожидание порта append-only + retention-purge).
|
||||
|
||||
**Npgsql-ветка проверена живьём** (временный тест против dev-Postgres, затем удалён): атомарный upsert
|
||||
(RETURNING), `GetCountAsync` (EF `SqlQuery<int>` с алиасом `Value`), `ResetAsync`, `DeleteExpiredAsync` —
|
||||
round-trip OK. Это поймало реальную проблему: EF запрещает `SqlQuery` по `INSERT … RETURNING`
|
||||
(«non-composable SQL») — исправлено на raw `DbCommand`.
|
||||
|
||||
## Затронутые файлы
|
||||
|
||||
Созданы (core): `Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs`,
|
||||
`Deal.Api/Configuration/DataRetentionOptions.cs`, `Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs`,
|
||||
`Deal.Api/Hosting/DataRetentionScheduler.cs`, `Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs`,
|
||||
`Deal.Infrastructure/Persistence/RateLimitCounterConfiguration.cs`,
|
||||
`Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs`,
|
||||
`Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters(.Designer).cs` +
|
||||
обновлён `DealDbContextModelSnapshot.cs`; тесты — `FakeRateLimitCounterStore`, `RateLimitCounterStoreTests`,
|
||||
`DataRetentionSchedulerTests`.
|
||||
Изменено (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/Program.cs`, `Deal.Api/appsettings.json`, `Deal.Infrastructure/Persistence/DealDbContext.cs`,
|
||||
`.../Repositories/{AuditLogStore,TenantLimitStore}.cs`, `Deal.Infrastructure/ServiceCollectionExtensions.cs`,
|
||||
`Deal.Modules.Tenants/Application/{AuthService,TenantStatuses,IAuditLogStore,ITenantLimitStore}.cs`.
|
||||
|
||||
## Concerns
|
||||
|
||||
- **Дополнительный запрос на запрос** (B3): резолв сессии теперь читает статус тенанта из `public.tenants`
|
||||
(третий запрос hot-path). Приемлемо для админ-плоскости; при необходимости оптимизируется join'ом в
|
||||
EF-адаптере сессии (не делал — минимизировал изменения портов).
|
||||
- **Флак фонов собирается не мной**: в тестовом харнессе есть давняя гонка `FreeTcpPort()` (порт
|
||||
освобождается до `StartAsync`), изредка дающая `AddressInUseException` в параллельном прогоне
|
||||
(`TelegramIngressTestHost`/`JoinEndpointHttpTests` и др.). Логика пакета B детерминирована; при повторе
|
||||
прогон зелёный. Общий фикс харнесса — отдельная задача.
|
||||
- **Окно выровнено по границам UTC** (B1): тесты, проверяющие «2 разрешены, 3-й 429» в пределах минуты,
|
||||
теоретически могут пересечь границу минуты (~доли процента). Сделано осознанно — распределённый лимит
|
||||
требует общих границ окна; in-memory-эталон якорился на момент создания лимитера.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Task C1 — фронтенд-производительность (пакет C)
|
||||
|
||||
Дата: 2026-09-10. Каталог работ: `src/frontend` (вне его изменения только ledger/этот отчёт).
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Разбиение бандла (устранение предупреждения >500 kB)
|
||||
|
||||
`vite.config.js` → `build.rollupOptions.output.manualChunks`:
|
||||
|
||||
- `vendor` — всё из `node_modules` (vue и рантайм);
|
||||
- `i18n` — словари локалей `src/i18n/locales/**` (`ru.js`, `ru.data.js`);
|
||||
- `app` — остальной код приложения (default-чанк).
|
||||
|
||||
Словарь остаётся в **статическом** графе импортов (нужен на первом рендере) — вынос в
|
||||
отдельный чанк не делает его ленивым и не ломает загрузку: браузер тянет `i18n`-чанк
|
||||
параллельно основному. `build.chunkSizeWarningLimit` **не повышался**.
|
||||
|
||||
Размеры чанков (minified, gzip):
|
||||
|
||||
| Чанк | До | После |
|
||||
|---------------------|------------|------------|
|
||||
| `index` (app) | 501.74 kB (144.90 kB) | **309.02 kB (78.89 kB)** |
|
||||
| `vendor` | — | 79.59 kB (31.54 kB) |
|
||||
| `i18n` | — | 113.65 kB (34.35 kB) |
|
||||
| `OperatorConsole` | 53.49 kB | 53.55 kB |
|
||||
| `JoinView` | 6.51 kB | 6.57 kB |
|
||||
| `index.css` | 63.01 kB | 63.04 kB |
|
||||
|
||||
Итог: предупреждение `Some chunks are larger than 500 kB` ушло, сборка зелёная.
|
||||
|
||||
### 2. Прогрессивный рендер длинных колонок
|
||||
|
||||
- Новый переиспользуемый примитив `src/composables/progressive.js` —
|
||||
`useProgressiveList(source, { pageSize = 100 })`. Рендерит первые `pageSize`
|
||||
элементов, считает число скрытых, отдаёт `showMore()` (добавляет следующую порцию).
|
||||
Лимит автоматически сжимается при уменьшении списка (очистка колонки/фильтр).
|
||||
- Единственное место рендера списка карточек — `ContainerColumn.vue`
|
||||
(`<Card v-for>`, строка ~365). Он общий для канбана дашборда и стадий «Выбранных»,
|
||||
поэтому дублей нет. Заменено на `visibleCards` + кнопка «Показать ещё N» при
|
||||
`hasMore`. Кнопка использует существующий стиль (как в `ProcessingView`).
|
||||
- Внешний вид и тексты 1:1 при малых списках: кнопка и срез появляются только
|
||||
при числе карточек > 100. Тяжёлая виртуализация не вводилась.
|
||||
- Новая строка — только через словарь: `common.showMoreCount` = «Показать ещё {count}».
|
||||
|
||||
### 3. Линтер i18n
|
||||
|
||||
`npm run lint:i18n` рабочий и зелёный. В `npm run build` **не добавлен** (по заданию).
|
||||
Скрипт уже был в `package.json`; добавлен `src/frontend/README.md` с описанием команд,
|
||||
локализации, разбиения бандла и прогрессивного рендера.
|
||||
|
||||
## Проверка
|
||||
|
||||
```
|
||||
npm run build → ✓ built, без предупреждения >500 kB
|
||||
app 309.02 kB, vendor 79.59 kB, i18n 113.65 kB
|
||||
npm run lint:i18n → ✓ кириллических пользовательских строк вне словарей не найдено
|
||||
```
|
||||
|
||||
Новых зависимостей нет. Dev-сервер не поднимался, порт :5173 свободен (проверено
|
||||
`netstat`; запущенных процессов не осталось).
|
||||
|
||||
## Что осталось (вне фронта, пакет C)
|
||||
|
||||
- `telegram-service`: LRU-кэши WTelegram.
|
||||
- Механизм миграций на 1000 схем.
|
||||
- Включение `lint:i18n` в общий прогон `scripts/test.sh` (делает отдельный агент).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Task C2 — производительность бэк/сервисы/скрипты (пакет C)
|
||||
|
||||
Дата: 2026-09-10. Каталог работ: `src/telegram-service`, `src/core`, `scripts`. Фронт
|
||||
(`src/frontend`) не затронут — его часть пакета закрыта в `task-c1-report.md`.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. LRU-кэши WTelegram (telegram-service)
|
||||
|
||||
Найдены неограниченные словари WTelegram-клиента в `WTelegramSessionClient` (растут всю жизнь
|
||||
процесса: access_hash, чаты/каналы, пользователи — накопительно по всем диалогам/поискам/discovery):
|
||||
|
||||
| Было | Стало | Ёмкость (именованная константа) |
|
||||
|---|---|---|
|
||||
| `Dictionary<string, long> _entityAccessHashes` | `LruCache<string, long>` | `AccessHashCacheCapacity = 2000` |
|
||||
| `Dictionary<long, ChatBase> _chatsById` | `LruCache<long, ChatBase>` | `ChatEntityCacheCapacity = 1000` |
|
||||
| `Dictionary<long, User> _usersById` | `LruCache<long, User>` | `UserEntityCacheCapacity = 2000` |
|
||||
|
||||
- Новый тип `Deal.Telegram/Caching/LruCache.cs` — LRU фиксированной ёмкости
|
||||
(Dictionary + LinkedList недавности, `Set`/`TryGetValue`/`ContainsKey`/`Count`/`Capacity`,
|
||||
`Touch` при чтении и обновлении). Не потокобезопасен намеренно: все обращения в клиенте уже
|
||||
сериализованы общим `_entityCacheGate` (комментарий в XML-doc типа это фиксирует).
|
||||
- Поведение сохранено: кэш — только оптимизация. Вытеснение access_hash уводит `ResolvePeerAsync`
|
||||
на прежний фолбэк (доливка `GetDialogsAsync` со страницей `DialogResolvePageSize`), вытеснение
|
||||
сущностей чата/пользователя — на прежний сетевой запрос `GetFullChannel`/`GetFullChat`.
|
||||
- `TryImportPeerFromCollector`/`BuildPeer`/`ResolveInputChannelAsync`/`CacheEntities` переведены
|
||||
на API LRU (`Set` вместо индексатора); контракты gRPC не менялись.
|
||||
- Обновлённые/новые unit-тесты: `Deal.Telegram.Tests/LruCacheTests.cs` (6 кейсов: вытеснение
|
||||
least-recently-used при переполнении, обновление недавности на чтении/записи, промах,
|
||||
`ContainsKey` без вытеснения, reject неположительной ёмкости, ёмкость 1).
|
||||
|
||||
### 2. Механизм миграций на 1000 схем (SaaS)
|
||||
|
||||
**Идемпотентность подтверждена (менять не потребовалось):** `TenantProvisioningService` уже
|
||||
выполняет `CREATE SCHEMA IF NOT EXISTS` и `TenantDbContext.Database.MigrateAsync`, а EF Core
|
||||
сверяется с таблицей истории `__TenantMigrationsHistory` схемы тенанта и применяет **только
|
||||
неприменённые** миграции. Повторный bootstrap/прогон ничего не переприменяет.
|
||||
|
||||
Добавлено недостающее — пакетная (maintenance) миграция всех существующих схем:
|
||||
|
||||
- `Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs` — обходит реестр
|
||||
(`ITenantRepository.ListAsync`) через `Parallel.ForEachAsync` с ограничением параллелизма
|
||||
(`DefaultMaxParallelism = 4`, кламп `[1..32]`), логирует прогресс «{Done}/{Total} — {Schema} готова».
|
||||
Сбой одной схемы не прерывает остальные: ошибка логируется, схема попадает в `failedSchemas`.
|
||||
- `Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs` — сводка
|
||||
`{Ok, Total, Migrated, Failed, DurationMs, FailedSchemas}`.
|
||||
- `Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs` — `POST /api/operator/maintenance/tenants/migrate`
|
||||
(только оператор, 401 без сессии), ответ `{ok, total, migrated, failed, failedSchemas, durationMs}`.
|
||||
- `TenantBootstrapService` переведён на тот же сервис: провижининг схем всех тенантов при старте
|
||||
стал параллельным с прогресс-логом (раньше — последовательный `foreach` без логов). Bootstrap
|
||||
не сломан: dev-seed дефолтного тенанта/admin и its «всегда провижинить реестр» сохранены,
|
||||
идемпотентность та же.
|
||||
- Регистрация: `AddDealPersistence` → `AddScoped<TenantSchemaMigrationService>`; ручка смонтирована
|
||||
в `Program.cs`.
|
||||
- Тесты: `TenantSchemaMigrationServiceTests.cs` (пустой реестр, миграция всех схем, сбой одной
|
||||
схемы не прерывает остальные) + `OperatorMaintenanceEndpointsHttpTests.cs` (401 без оператора,
|
||||
сводка 200 на двух тенантах). Тест-дубль `FailingTenantProvisioner`; `FakeTenantProvisioner`
|
||||
сделан потокобезопасным (параллельный прогон).
|
||||
|
||||
### 3. Линтер i18n в общий прогон
|
||||
|
||||
`scripts/test.sh` был минимальным (только `dotnet test` core). Расширен:
|
||||
`dotnet test tests/Deal.Tests.Unit` → затем `npm run lint:i18n` из `src/frontend`.
|
||||
Если `npm` не установлен — шаг мягко пропускается с сообщением (не фатально); при наличии npm
|
||||
падение линтера валит прогон (это и есть гейт). `cd` выполняется в подоболочке, рабочая
|
||||
директория вызывающего не меняется; сохранён POSIX-sh стиль скрипта (`sh -n` — OK).
|
||||
|
||||
## Проверка
|
||||
|
||||
```
|
||||
src/telegram-service:
|
||||
dotnet build Deal.Telegram.sln -v q --nologo → 0 warnings / 0 errors
|
||||
dotnet test Deal.Telegram.sln → всего 125; сбой 0; успешно 125 (+6)
|
||||
|
||||
src/core:
|
||||
dotnet build Deal.sln -v q --nologo → 0 warnings / 0 errors
|
||||
dotnet test tests/Deal.Tests.Unit/…csproj → всего 1189; сбой 0; успешно 1189 (+5)
|
||||
|
||||
scripts:
|
||||
sh -n scripts/test.sh → syntax ok
|
||||
sh scripts/test.sh → core 1189/1189 + ✓ i18n + «TEST ОК»
|
||||
```
|
||||
|
||||
Docker/Postgres не поднимались; запущенных процессов и контейнеров не осталось
|
||||
(`dotnet build-server shutdown` выполнен).
|
||||
|
||||
## Что осталось (вне этой задачи)
|
||||
|
||||
- Пакет D (reclassify-проводка + учёт токенов ML).
|
||||
- Опционально (продуктовое решение): вынести параллелизм пакетной миграции в конфиг/env вместо
|
||||
константы `DefaultMaxParallelism`; курсор/шардирование обхода реестра для многих тысяч схем.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Task: Cleanup мусора (leadradar-legacy)
|
||||
|
||||
Дата: 2026-09-11
|
||||
Корень: `C:\telbase` (не git — удаления необратимы, работал консервативно).
|
||||
|
||||
## Инвентаризация (до)
|
||||
|
||||
`du -sh .` = **498M**
|
||||
|
||||
Крупные каталоги:
|
||||
| Путь | Размер |
|
||||
|---|---|
|
||||
| `src/` | 424M |
|
||||
| `data/` | 68M |
|
||||
| `.superpowers/` | 2.7M |
|
||||
| `Стиль_кода.docx` | 1.5M |
|
||||
| `archive/` | 1.2M |
|
||||
| `docs/` | 932K |
|
||||
|
||||
Артефакты сборки .NET (`bin/`+`obj/` под `src/**`, кроме `node_modules`): суммарно **359M**.
|
||||
|
||||
Корневые легаси-файлы прототипа 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).
|
||||
|
||||
## Выполнено
|
||||
|
||||
### Удалено (безопасно)
|
||||
- Все **44** каталога `bin/`/`obj/` под `src/**` от .NET-проектов (ai-service, contracts, core/*, grpc-hosting, ml-service, telegram-service), кроме `bin/` внутри `node_modules` — их не трогал.
|
||||
- `cookies.txt` (дамп Netscape cookies, `leadradar_session`).
|
||||
- `settings.json` (дамп настроек прототипа LeadRadar: aiProvider/aiPrompt и т.п.).
|
||||
- `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/`).
|
||||
- `.env` → `archive/leadradar-legacy/.env`
|
||||
- `.env.example` → `archive/leadradar-legacy/.env.example`
|
||||
|
||||
Актуальные env — `deploy/compose*.yml` и `deploy/.env*.example` — не затронуты.
|
||||
|
||||
## Оставлено осознанно
|
||||
- **`src/frontend/dist/`** — артефакт сборки фронта, но в задаче явно не перечислен. Оставлен (консервативно); пересобирается `npm run build`.
|
||||
- `src/frontend/node_modules` — нужен для запуска/сборки фронта.
|
||||
- `docs/`, `.superpowers/`, `archive/`, `deploy/`, `scripts/`, `ТЗ.md`, `Стиль_кода.docx`, `README.md`, `.editorconfig`, `.dockerignore` — по условию не трогать.
|
||||
- `node_modules/.cache` — отсутствует (нечего удалять).
|
||||
- `TestResults/`, `*.user`, `*.suo`, `*.nupkg`, `*.tmp`, `*.bak`, `*.orig`, `*~`, `~$*`, `.DS_Store`, `Thumbs.db` — не найдены.
|
||||
|
||||
## Итог по размеру
|
||||
|
||||
| | До | После |
|
||||
|---|---|---|
|
||||
| Корень `C:\telbase` | 498M | **140M** |
|
||||
| `src/` | 424M | 66M |
|
||||
| `archive/` | 1.2M | 69M (данные перенесены, не удалены) |
|
||||
|
||||
**Освобождено ~358M.**
|
||||
|
||||
## Результаты валидации
|
||||
|
||||
- `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.
|
||||
- `cd src/frontend && npm run build` → `✓ built in 1.78s` (111 modules).
|
||||
- `npm run lint:i18n` → `✓ кириллических пользовательских строк вне словарей не найдено`.
|
||||
- `dotnet build-server shutdown` → MSBuild/Roslyn серверы остановлены.
|
||||
- `docker ps` → контейнеров нет, хвостов не осталось.
|
||||
|
||||
## Вывод
|
||||
Проект очищен от build-артефактов и легаси прототипа LeadRadar. Ничего необратимо важного не удалено: данные и env прототипа перемещены в `archive/leadradar-legacy/`. Сборка .NET, тесты и фронтенд — зелёные.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Task D report — reclassify на реальном ИИ + учёт токенов ML (пакет D)
|
||||
|
||||
План: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md` (§Пакет D).
|
||||
Контракт: `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify).
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Реальная переклассификация (batch + одиночная)
|
||||
|
||||
Новый сервис `Deal.Modules.Pipeline/Application/CardReclassifier.cs` прогоняет карточку(и) через **тот же
|
||||
конвейер**, что и «filtered»-проход воркера (прототип `leads.py reclassify_lead L292–389`), но **без создания
|
||||
новой карточки** — обновляется существующая:
|
||||
|
||||
1. фильтр `IAiClassifier.FilterAsync` (при `aiEnabled`; уважает выключатель `aiFilterEnabled`, сбой → пропуск);
|
||||
2. классификация `IAiClassifier.ClassifyAsync` (успех → `is_vacancy_known=true`; сбой → локальный разбор);
|
||||
3. спам/непройденный фильтр → корзина + сигнал ML «спам» (вес ИИ 0.4);
|
||||
4. сборка контента `CardComposer` (заголовок/«О заявке»/стек/бюджет+конверсия/контакты) и страховка
|
||||
`ColumnRules.ContainerAccepts` (доска из разбора принимается только если текст прошёл её правила);
|
||||
5. атомарное обновление карточки одним запросом;
|
||||
6. обучающие сигналы ML (доска свободной колонки + тип `t:hire`/`t:order`) — как в пайплайне.
|
||||
|
||||
**Без кредов/ИИ не падает:** когда `aiEnabled=false` или классификатор недоступен/сбой — детерминированный
|
||||
локальный разбор `LocalFieldsParser` + `AiCardMapper.FromLocal` (ветка воркера `aiEnabled=false`/`aiFail`),
|
||||
ответ помечается `usedAi=false`.
|
||||
|
||||
**Одиночная переклассификация добавлена** (в контракте есть `POST /api/cards/{cardId}/reclassify`, но роута
|
||||
не было): любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
|
||||
|
||||
**Одна переклассификация за раз** — `ReclassifyGate` (singleton, `SemaphoreSlim`, неблокирующий вход): занятый
|
||||
проход отвечает `{started:false, busy:true}`, как фоновая задача прототипа.
|
||||
|
||||
### 2. Переиспользование без дублей
|
||||
|
||||
- `ICardStore.ApplyReclassificationAsync(CardReclassificationDto, ct)` — одно обновление полей классификации
|
||||
(`leads.py L346–367`): col/is_new/тип/title/summary/stack/budget/converted/contact/contacts/matchHits.
|
||||
Реализация — `KanbanStore.Cards.cs` (+ `FakeKanjStore`).
|
||||
- Обучение ML вынесено из воркера в общий `AiCardLearning.PushSignalsAsync` (воркер + переклассификация);
|
||||
поведение воркера не изменилось.
|
||||
- Вес сигнала ИИ централизован: `MlLearningLabels.AiPushWeight = 0.4` (был приватный const в воркере).
|
||||
- Корзина — через существующий `CardsService.TrashCardAsync`; добавлен параметр `teach` (1:1 с прототипом
|
||||
`trash_lead(teach=...)`): `teach=false` — журнал `action=trash` пишется, а сигнал «спам» кладёт
|
||||
переклассификация явно с весом ИИ 0.4 (вместо 1.0 действия пользователя).
|
||||
|
||||
### 3. Итоговая форма ответа (совместимо расширена)
|
||||
|
||||
Сохранены: `started`, `busy`, `attempted`. Добавлено: `reclassified`, `moved`, `kept`, `trashed`, `skipped`,
|
||||
`usedAi`, `reason`.
|
||||
|
||||
```json
|
||||
{
|
||||
"started": true, "busy": false, "attempted": 3, "reclassified": 3,
|
||||
"moved": 1, "kept": 1, "trashed": 1, "skipped": 0, "usedAi": false, "reason": null
|
||||
}
|
||||
```
|
||||
|
||||
Фронт (`reclassifyInbox`) читает только `started/busy/attempted/reason` — контракт совместим: `started=true`
|
||||
по-прежнему включает тост и перезагрузку доски. Зафиксировано в контрактном документе (только раздел reclassify).
|
||||
|
||||
Дополнительно: аудит `AuditEvents.CardReclassified = "card_reclassified"` (пишется только при
|
||||
`reclassified > 0`).
|
||||
|
||||
## Статус учёта токенов ML
|
||||
|
||||
Проверено, **всё уже покрыто** (код не менялся):
|
||||
|
||||
| Путь | Учёт | Где |
|
||||
|---|---|---|
|
||||
| Платный ИИ (Filter/Classify/…) | бюджет `tenant_limits` + lifetime KV `aiTokenUsage` + событие `kind=ai` + метрики `deal.ai.*` | `GrpcAiClassifier`/`GrpcAiTools` → `TokenUsageRecorder.AddAsync` |
|
||||
| **reclassify через ИИ** | покрыт автоматически — идёт через тот же `IAiClassifier` (адаптер сам пишет usage) | `GrpcAiClassifier` |
|
||||
| Реальный ML-predict (gRPC) | событие `kind=ml` (оценка ≈chars/4) + метрики `deal.ml.calls`/`deal.ml.tokens` | `GrpcMlClient.PredictAsync` → `TokenUsageRecorder.AddEstimatedAsync` |
|
||||
| Локальные ИИ/ML-адаптеры | **не пишут** — осознанное решение этапа 10 (dev-заглушки бесплатны) | `LocalAiClassifier`/`LocalMlClient` |
|
||||
| Батчевые предсказания | в core нет batch-predict; predict идёт по сообщению (pump/Discovery/manual) | запись на каждый вызов |
|
||||
|
||||
Переклассификация **не делает ML-predict** (как и прототип `reclassify_lead`) — только классификацию и
|
||||
обучающие `push` (очередь обучения, токенами не тарифицируется). Отдельного «reclassify-ML»-учёта не требуется.
|
||||
|
||||
## Файлы
|
||||
|
||||
Создано:
|
||||
- `Deal.Modules.Pipeline/Application/CardReclassifier.cs`
|
||||
- `Deal.Modules.Pipeline/Application/AiCardLearning.cs`
|
||||
- `Deal.Modules.Pipeline/Application/ReclassifyGate.cs`
|
||||
- `Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs`
|
||||
- `Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs`
|
||||
- `tests/Deal.Tests.Unit/CardReclassifierTests.cs`
|
||||
|
||||
Изменено:
|
||||
- `Deal.Contracts/Integrations/MlLearningLabels.cs` (+`AiPushWeight`)
|
||||
- `Deal.Modules.Pipeline/Application/PipelineWorkerService.cs`/`.Learning.cs`/`.Pump.cs` (общий `AiCardLearning`,
|
||||
централизованный вес; поведение воркера не изменилось)
|
||||
- `Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs` (DI)
|
||||
- `Deal.Modules.Kanban/Application/ICardStore.cs` + `CardsService.Operations.cs` (`teach`)
|
||||
- `Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs`
|
||||
- `Deal.Api/Endpoints/CardsEndpoints.cs` (+роут `/{cardId}/reclassify`, реальный batch)
|
||||
- `Deal.Modules.Tenants/Application/AuditEvents.cs` (+`card_reclassified`)
|
||||
- `tests/Deal.Tests.Unit/FakeKanjStore.cs`, `AuditEventsTests.cs`
|
||||
- `docs/architecture/2026-09-10-unified-api-contract.md` (§reclassify)
|
||||
|
||||
## Проверка
|
||||
|
||||
- `dotnet build Deal.sln -v q --nologo` — **0/0**.
|
||||
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — **1203/1203 PASS** (+14: 13 новых
|
||||
CardReclassifierTests + 1 inline-кейс аудита). Покрыты: путь без ИИ (local fallback, порт не зовётся),
|
||||
сбой классификатора → local, ИИ-доска (ContainerAccepts/страховка/обновление/сигналы ML), hits правил,
|
||||
спам → корзина + push 0.4, отсев фильтром, пустой inbox, фильтр по ids, пропуск без текста, одиночный
|
||||
no-text, сохранение валидного старого контакта, busy-замок.
|
||||
- Docker/Postgres не поднимались; `dotnet build-server shutdown` не требовался (нет запущенных процессов).
|
||||
|
||||
## Что осталось (вне этой задачи)
|
||||
|
||||
- SSE `leads_reclassified` и финальный тост «готово» прототипа не публикуются: переклассификация синхронная,
|
||||
фронт перезагружает доску по `started` (как и раньше, SSE-события boards_changed/leads_reclassified в core
|
||||
не публикуются — см. отчёт этапа 3, T15).
|
||||
- Пофайловый streaming-прогресс батча (прототип — фоновая задача с тостами) не делался: проход синхронный,
|
||||
single-flight через `busy`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Task E report — закрытие двух остатков после этапа 12 (SDD, автономный заход)
|
||||
|
||||
План этапа: `docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md`.
|
||||
Контракт (обновлён только раздел SSE/reclassify): `docs/architecture/2026-09-10-unified-api-contract.md`.
|
||||
|
||||
## 1. Убран дубляж и гонка `FreeTcpPort()` в тест-харнессе core
|
||||
|
||||
Размноженные приватные копии `FreeTcpPort()` (10 штук в `src/core/tests/Deal.Tests.Unit`) искали свободный
|
||||
порт связкой «биндинг `127.0.0.1:0` → немедленное освобождение». При параллельном прогоне ОС могла выдать
|
||||
один и тот же освобождённый эфемерный порт двум тестам — второй бинд Kestrel/gRPC падал с
|
||||
`AddressInUseException` (`SocketException`).
|
||||
|
||||
**Новый единый хелпер** `tests/Deal.Tests.Unit/TestPort.cs` (1 тип = 1 файл):
|
||||
|
||||
- `TestPort.Allocate()` биндит порт 0, читает выданный ОС порт и освобождает слушатель;
|
||||
- выданные в процессе порты запоминаются в `ConcurrentDictionary` и **повторно не выдаются** — при коллизии
|
||||
биндинг порта 0 повторяется (до `MaxAttempts = 64`), поэтому порты не пересекаются между тестами;
|
||||
- при исчерпании попыток — понятное `InvalidOperationException`.
|
||||
|
||||
Все 10 копий заменены на `TestPort.Allocate()`; приватные методы удалены, неиспользуемый
|
||||
`using System.Net.Sockets;` убран (`using System.Net;` сохранён — он нужен для `IPAddress.Loopback`).
|
||||
Поведение тестов не менялось.
|
||||
|
||||
Затронуты: `AiGrpcTestHost.cs`, `MlGrpcTestHost.cs`, `TelegramGrpcTestHost.cs`, `TelegramIngressTestHost.cs`,
|
||||
`ServiceHealthProbeTests.cs`, `OperatorAuthHttpHost.cs`, `ForwardedHeadersHttpTests.cs`,
|
||||
`JoinEndpointHttpTests.cs`, `OriginGuardHttpTests.cs`, `RateLimitHttpTests.cs`.
|
||||
|
||||
## 2. SSE-событие о завершении переклассификации
|
||||
|
||||
### Бэкенд
|
||||
|
||||
`Deal.Api/Endpoints/CardsEndpoints.cs`:
|
||||
|
||||
- новый тип события `cards_reclassified` (константа `ReclassifiedEventType`), публикуется из **обоих**
|
||||
эндпоинтов (`POST /api/cards/reclassify` и `POST /api/cards/{cardId}/reclassify`);
|
||||
- публикация — после успешного прохода: `started && reclassified > 0`. Пустой inbox / всё пропущено доску не
|
||||
меняют — событие не шлётся (нет лишней перезагрузки на фронте);
|
||||
- payload минимальный, camelCase: `{ reclassified, moved }` — сколько обработано и перемещено;
|
||||
- публикует `SseBroker` в канал тенанта сессии (`context.GetCurrentUser()!.TenantId`), без подписчиков —
|
||||
no-op (Ruling 5: публикации из Api).
|
||||
|
||||
### Фронт
|
||||
|
||||
- `src/frontend/src/api.js` — в `openEvents` добавлен `es.addEventListener('cards_reclassified', …)`
|
||||
(ранее событие не регистрировалось и не доходило до store);
|
||||
- `src/frontend/src/store/lifecycle.js` — ветка обработки переименована с мёртвого `leads_reclassified` на
|
||||
`cards_reclassified`: мягкий `reloadBoardData()` (контейнеры + карточки) и `refreshMlStatus()`;
|
||||
поведение существующих событий не менялось.
|
||||
|
||||
Текущее поведение сохранено: синхронный ответ по-прежнему тостит и перезагружает доску по `started`, а SSE
|
||||
дополнительно закрывает случай «доска в другой вкладке/фоновая переклассификация». Новых пользовательских
|
||||
строк нет (событие без тоста) — словарь i18n не требуется.
|
||||
|
||||
## Файлы
|
||||
|
||||
Создано:
|
||||
- `src/core/tests/Deal.Tests.Unit/TestPort.cs`
|
||||
|
||||
Изменено:
|
||||
- `src/core/tests/Deal.Tests.Unit/` — 10 файлов (замена `FreeTcpPort()` на `TestPort.Allocate()`)
|
||||
- `src/core/Deal.Api/Endpoints/CardsEndpoints.cs` (+`using Deal.Api.Events;`, `ReclassifiedEventType`,
|
||||
`PublishReclassified`, публикация в обоих reclassify-эндпоинтах)
|
||||
- `src/frontend/src/api.js`, `src/frontend/src/store/lifecycle.js`
|
||||
- `docs/architecture/2026-09-10-unified-api-contract.md` (только раздел SSE и reclassify)
|
||||
|
||||
## Проверка
|
||||
|
||||
- `dotnet build Deal.sln -v q --nologo` — **0/0** (без предупреждений).
|
||||
- `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 lint:i18n` — зелёно («кириллических пользовательских строк вне словарей не найдено»).
|
||||
- `dotnet build-server shutdown` выполнен, запущенных процессов не осталось.
|
||||
|
||||
## Что осталось (вне этой задачи)
|
||||
|
||||
- Двойная перезагрузка доски у инициатора batch-запроса: ответ эндпоинта (`started` → `reloadBoardData`) и
|
||||
SSE-событие. Осознанно оставлено ради «не ломать текущее поведение» и работы при отвале SSE; при желании
|
||||
можно убрать reload из `reclassifyInbox`, положившись только на событие.
|
||||
- Пофайловый streaming-прогресс батча и финальный тост «готово» прототипа не делались (проход синхронный).
|
||||
- Мёртвые ветки `boards_changed`/`pipeline_stats` во фронте: core этих событий по-прежнему не публикует
|
||||
(унаследовано от этапа 3; вне задачи).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Task — Финальный отчёт: частичное обновление Telegram-ключей, миграция GlobalSettings, сквозная проверка
|
||||
|
||||
Дата: 2026-09-10. Область: `src/core` (+ контрактный док, отчёты в `.superpowers`). Фронт/сервисы/`deploy`
|
||||
не менялись (deploy только поднимался/гасился для проверки миграции).
|
||||
|
||||
## 1. Частичное обновление ключей Telegram
|
||||
|
||||
Ручка `PUT /api/operator/settings/telegram-keys` теперь принимает **частичное** тело `{apiId?, apiHash?}`:
|
||||
|
||||
| Вход | Поведение |
|
||||
|---|---|
|
||||
| `{apiId, apiHash}` | полное обновление, как раньше |
|
||||
| `{apiId}` | обновляет `apiId`, `apiHash` берётся из текущих ключей |
|
||||
| `{apiHash}` | обновляет `apiHash`, `apiId` берётся из текущих ключей |
|
||||
| `{}` (ни одного поля) | `400 {detail: "Укажите api_id и api_hash"}` |
|
||||
| частичное, но ключей ещё нет | `400 {detail: "Ключи ещё не заданы — укажите и api_id, и api_hash"}` |
|
||||
|
||||
Правила:
|
||||
- `null`/отсутствие поля = «не менялось» (берём текущее значение).
|
||||
- Явное значение (в т.ч. пустая строка) валидируется прежними правилами: `apiId` — 5–9 цифр,
|
||||
`apiHash` — непустой, не маска (`…`), без префикса `enc:`.
|
||||
- Недостающее поле, которого ещё нет в хранилище, → `400` (нельзя «дополнить» отсутствующее значение).
|
||||
- Ответ — прежняя маска-форма `{apiId, apiHash(маска), keysSet}`; аудит `telegram_keys_changed`
|
||||
(`{apiId: <эффективный>, apiHashSet: true}` — секрет не пишется).
|
||||
|
||||
Реализация — в `Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` (`PutTelegramKeysAsync`): читает текущий
|
||||
снимок через `TelegramKeysService.GetAsync`, сливает с переданными полями, валидирует и сохраняет
|
||||
`SaveAsync(effectiveApiId, effectiveApiHash)`. Строгая валидация самого сервиса не менялась
|
||||
(defence in depth).
|
||||
|
||||
Изменённые файлы:
|
||||
- `src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs` — логика слияния, новый `400`-detail, XML-doc.
|
||||
- `src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs` — doc: поля опциональны.
|
||||
- `docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел PUT (примеры, семантика, ошибки).
|
||||
- `src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs` — +4 теста:
|
||||
`PutTelegramKeys_OnlyApiId_KeepsExistingApiHash`, `PutTelegramKeys_OnlyApiHash_KeepsExistingApiId`,
|
||||
`PutTelegramKeys_PartialWithoutExistingKeys_Returns400`, `PutTelegramKeys_NoFields_Returns400`.
|
||||
Прежние кейсы (пустой/маскированный `apiHash`, невалидный `apiId`, 401) сохранены.
|
||||
|
||||
## 2. Миграция `GlobalSettings`
|
||||
|
||||
- Postgres поднят: `docker compose -f deploy/compose.dev.yml up -d postgres` (контейнер `deal-postgres`,
|
||||
хост-порт `5433`).
|
||||
- Применение: `dotnet ef database update --context DealDbContext --project Deal.Infrastructure
|
||||
--startup-project Deal.Api --no-build` со строкой подключения
|
||||
`Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password`
|
||||
(env `ConnectionStrings__DealPostgres`).
|
||||
Вывод: `Applying migration '20260910194443_GlobalSettings'. Done.`
|
||||
|
||||
> Отклонение от формулировки задания: `--startup-project Deal.Infrastructure` не работает — это
|
||||
> class library без entry point (design-time factory в репозитории отсутствует); штатный startup —
|
||||
> `Deal.Api` (так же в техдоке §13.2 и прошлых задачах). Env-переменная конфигурации —
|
||||
> `ConnectionStrings__DealPostgres` (ключ `ConnectionStrings:DealPostgres`), не `DEAL_PG_CONNECTION`.
|
||||
|
||||
**Проверка таблицы** (`docker exec deal-postgres psql -U deal -d deal -c "\d global_settings"`):
|
||||
|
||||
```
|
||||
Table "public.global_settings"
|
||||
Column | Type | Nullable |
|
||||
-----------+--------------------------+----------+
|
||||
Key | character varying(200) | not null |
|
||||
Value | text | not null |
|
||||
UpdatedAt | timestamp with time zone | not null |
|
||||
Indexes:
|
||||
"PK_global_settings" PRIMARY KEY, btree ("Key")
|
||||
```
|
||||
|
||||
В `public.__EFMigrationsHistory` последняя запись — `20260910194443_GlobalSettings`.
|
||||
|
||||
- Postgres погашен: `docker compose -f deploy/compose.dev.yml down` (удалены контейнер и сеть).
|
||||
|
||||
## 3. Сквозная проверка (все 4 решения)
|
||||
|
||||
| Решение | Build | Тесты |
|
||||
|---|---|---|
|
||||
| `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/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** |
|
||||
|
||||
Команды: `dotnet build <sln>` и `dotnet test <sln> --no-build` из каталогов решений. Во всех решениях
|
||||
`TreatWarningsAsErrors=true` (`Directory.Build.props`), поэтому успешная сборка = 0 warnings.
|
||||
|
||||
- **Легаси-перенос.** Сборка/тесты всех решений зелёные — перенос в `archive/leadradar-legacy/` ничего
|
||||
не сломал (проекты на него не ссылаются).
|
||||
- **Хвостов нет:** `docker ps -a` — контейнеров `deal-*` нет (postgres удалён), `dotnet build-server
|
||||
shutdown` выполнен, порты `5433/5080/5082/5101/5102/5103` без LISTENING.
|
||||
|
||||
## Замечания / на будущее
|
||||
|
||||
- `docs/api/api-map.md` по-прежнему не описывает `GET/PUT /api/operator/settings/telegram-keys`
|
||||
(из отчёта фронт-задачи) — контракт живёт в `docs/architecture/2026-09-10-operator-analytics-contract.md`.
|
||||
Вне области этой задачи.
|
||||
- Фронтенд-форма (`TelegramSection.vue`) шлёт оба поля всегда — частичный режим обратно совместим,
|
||||
UI-правок не требует (при желании можно отправлять только изменённые поля).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Task — Telegram-ключи: фронтенд (вариант A, глобальные ключи оператора)
|
||||
|
||||
Дата: 2026-09-10. Область: только `src/frontend` (+ этот отчёт и ledger). Без новых зависимостей.
|
||||
Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Тёмная/светлая темы не ломались
|
||||
(новые элементы — на существующих токенах). Процессы не запускались, `:5173` свободен.
|
||||
|
||||
Источник формы ручек — контракт `docs/architecture/2026-09-10-operator-analytics-contract.md`
|
||||
(раздел «Операторские настройки: глобальные ключи Telegram») и фактический код
|
||||
`src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, `TelegramEndpoints.cs`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Оператор-консоль: новый раздел «Telegram»
|
||||
|
||||
**Новые/изменённые файлы:**
|
||||
|
||||
- `src/frontend/src/views/operator/TelegramSection.vue` — новый раздел.
|
||||
- `src/frontend/src/views/operator/OperatorConsole.vue` — раздел добавлен в `SECTIONS`
|
||||
(`id: 'telegram'`, иконка `key`) после «Состояния системы».
|
||||
- `src/frontend/src/store/operator.js` — состояние `op.tgKeys` и действия `loadTelegramKeys()` /
|
||||
`saveTelegramKeys(payload)`.
|
||||
- `src/frontend/src/api.js` — в клиент добавлен `api.put` (метода не было; используется
|
||||
исключительно новой ручкой, других вызовов PUT во фронте нет).
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- `GET /api/operator/settings/telegram-keys` при монтировании: бейдж «Ключи заданы / не заданы»,
|
||||
показ открытого `apiId` и **маски** `apiHash`; если ключей нет — предупреждающий текст, что
|
||||
подключение аккаунтов у тенантов недоступно.
|
||||
- Форма `api_id` + `api_hash` (секрет — `type=password`, `autocomplete=new-password`), кнопка
|
||||
«Сохранить ключи» → `PUT` с `{apiId, apiHash}`.
|
||||
- Клиентская валидация 1:1 контракту: `api_id` — строго 5–9 цифр, `api_hash` — непустой; ошибки
|
||||
выводятся инлайн через `:error` у `TextInput`.
|
||||
- Серверные 400 показываются точным текстом бэка (тост): `localizeError` при статусе 400 отдаёт
|
||||
`detail` приоритетнее общей формулировки (проверено по `src/i18n/errors.js`).
|
||||
- После успешного сохранения поле `api_hash` очищается (секрет на клиенте не удерживается), а
|
||||
снимок `op.tgKeys` берётся из ответа PUT.
|
||||
- 401 на `/api/operator/*` обрабатывается существующим `addUnauthorizedHandler` (гасит
|
||||
операторскую сессию и показывает тост).
|
||||
|
||||
Переиспользованы примитивы `SectionLayout`, `Card`, `Badge`, `Button`, `TextInput`, `Icon`;
|
||||
стиль — как у остальных разделов консоли.
|
||||
|
||||
## 2. Настройки тенанта: ключи Telegram убраны
|
||||
|
||||
**Файлы:** `src/frontend/src/components/settings/TelegramTab.vue`,
|
||||
`src/frontend/src/store/settings.js`, `src/frontend/src/store/core.js`.
|
||||
|
||||
- Из вкладки Telegram удалён весь блок «Ключи приложения (api_id / api_hash)» (поля ввода,
|
||||
инструкция my.telegram.org, кнопка сохранения, строка про сохранённые ключи).
|
||||
- Осталось подключение аккаунта: статус/логин, QR/телефон/код/2FA, авто-мониторинг новых чатов.
|
||||
- Если `GET /api/tg/status` вернул `keysSet:false` — над блоком подключения показывается
|
||||
предупреждение: «Ключи приложения Telegram не заданы / Подключение аккаунтов недоступно, пока
|
||||
оператор не задаст глобальные ключи…» (без деталей реализации). Кнопки QR/телефон по-прежнему
|
||||
отключены (`:disabled="!state.tgKeysSet"`), подпись рядом уточнена.
|
||||
- Из стора удалено всё, что относилось к публичным `tgKeys`: обработка `has('tgKeys')` в
|
||||
`applySettings`, функция `saveSettings()` целиком (была нужна только ключам), поля
|
||||
`state.apiId` / `state.apiHash` (и динамические `apiIdMask`/`apiHashSet`). Поле
|
||||
`state.tgKeysSet` **оставлено** — оно приходит из `/api/tg/status` и управляет доступностью
|
||||
подключения. Импорт `refreshTgStatus` из `settings.js` убран за ненадобностью.
|
||||
|
||||
## 3. i18n
|
||||
|
||||
- Добавлены ключи `operator.telegram*`, `operator.globalnye-klyuchi-telegram*`, `operator.klyuchi-*`,
|
||||
`operator.api-id-*`, `operator.api-hash*`, `operator.ukazhite-nepustoj-api-hash`,
|
||||
`operator.sohranit-klyuchi`, `operator.klyuchi-telegram-sohraneny`,
|
||||
`operator.smena-api-id-trebuet-api-hash`.
|
||||
- Добавлены ключи `settings.klyuchi-prilozheniya-ne-zadany`,
|
||||
`settings.podklyuchenie-akkauntov-nedostupno-poka-operator`,
|
||||
`settings.dostupno-posle-nastrojki-klyuchej-operatorom`.
|
||||
- Удалены «мёртвые» ключи формы ключей тенанта: `settings.klyuchi-prilozheniya-api-id-api-hash`,
|
||||
`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.i-vojdite-po-nomeru-telefona`, `settings.2-perejdite-v`,
|
||||
`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.bukvenno-cifrovoj-api-hash`, `settings.sohranit-klyuchi`,
|
||||
`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.klyuchi-telegram-sohraneny`. Ключ `settings.i` оставлен — используется в `AiTab`/`ScopeTab`.
|
||||
|
||||
## 4. Аудит (заодно, по каталогу событий контракта)
|
||||
|
||||
- В `AUDIT_EVENT_TYPES` (фильтр лент «Аудит»/«Аналитика» в консоли) добавлен
|
||||
`telegram_keys_changed` — событие, которое бэкенд пишет при `PUT` глобальных ключей.
|
||||
|
||||
---
|
||||
|
||||
## Проверка
|
||||
|
||||
- `npm run build` — ✓ (`vite build`, 111 модулей; `OperatorConsole` — 60.08 kB, основной чанк
|
||||
316.17 kB, предупреждений о размере нет).
|
||||
- `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет).
|
||||
- `netstat :5173` — совпадений нет, порт свободен; dev-сервер не поднимался.
|
||||
|
||||
## Найденные расхождения / замечания
|
||||
|
||||
1. **`docs/api/api-map.md` не описывает новую ручку.** В §6 («Реализовано в Deal») таблица
|
||||
операторских ручек есть, но `GET/PUT /api/operator/settings/telegram-keys` в неё не добавлена
|
||||
(контракт живёт только в `docs/architecture/2026-09-10-operator-analytics-contract.md`).
|
||||
Док не правил — вне области фронтенда; кандидат на синхронизацию бэкенд-агентом.
|
||||
2. **PUT требует оба поля.** По контракту `api_hash` всегда обязателен, поэтому сменить один
|
||||
`api_id`, не вводя hash заново, нельзя (секрет не возвращается). Это отражено подсказкой в
|
||||
форме: «Смена api_id требует повторного ввода api_hash». Если у владельца сценарий «сменить
|
||||
только api_id» — потребуется доработка контракта/ручки.
|
||||
3. **`state.tgKeysSet` в тенант-сторе** остаётся единственным «telegram-ключевым» полем — оно
|
||||
приходит из `/api/tg/status` (поле `keysSet`), а не из публичных настроек. Это осознанно:
|
||||
вкладка использует его для блокировки подключения.
|
||||
4. **Прокси-линтер `i18n-lint` и «Число с разделителями»** — не относится к задаче, поведение
|
||||
скрипта не менялось.
|
||||
|
||||
## Что осталось (вне области этой задачи)
|
||||
|
||||
- `docs/api/api-map.md` — добавить строку про `GET/PUT /api/operator/settings/telegram-keys`.
|
||||
- Применение миграции `GlobalSettings` к dev-Postgres (отложено бэкенд-агентом).
|
||||
- Живой прогон консоли против поднятого стека (в этой сессии внешние процессы не запускались).
|
||||
@@ -0,0 +1,123 @@
|
||||
# Task — Telegram-ключи: вариант A (глобальные ключи оператора). Отчёт
|
||||
|
||||
> Дата: 2026-09-10. Решение владельца — **вариант A**: `api_id`/`api_hash` задаёт оператор
|
||||
> глобально (ТЗ §4.1/§8.1), тенант только подключает аккаунт. Область: `src/core` + комментарии
|
||||
> `src/contracts/telegram.proto` + контрактная документация. Фронт, `telegram-service`, `deploy`,
|
||||
> `.superpowers` (кроме ledger) не трогались.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Глобальное хранилище ключей (`public.global_settings`)
|
||||
|
||||
- Модуль Settings:
|
||||
- `Application/IGlobalSettingsStore.cs` — порт KV-хранилища глобальных (системных) настроек
|
||||
(`GetAsync`/`SetAsync`, значения — JSON-строки).
|
||||
- `Application/GlobalSettingsKeys.cs` — каталог ключей; `TelegramKeys = "telegramKeys"`.
|
||||
- Инфраструктура:
|
||||
- `Persistence/Entities/GlobalSettingEntity.cs` (`Key`, `Value`, `UpdatedAt`).
|
||||
- `Persistence/GlobalSettingConfiguration.cs` — таблица `global_settings`, схема `public`, PK `Key`,
|
||||
`Key varchar(200)`, `Value text`.
|
||||
- `Persistence/DealDbContext` — `DbSet<GlobalSettingEntity> GlobalSettings` + `ApplyConfiguration`.
|
||||
- `Persistence/Repositories/GlobalSettingsStore.cs` — EF-адаптер (upsert, `UpdatedAt = UTC-now`).
|
||||
- `ServiceCollectionExtensions.AddDealPersistence` — `IGlobalSettingsStore → GlobalSettingsStore` (scoped).
|
||||
- Миграция: `Migrations/20260910194443_GlobalSettings.cs` (+ `.Designer.cs`, обновлён
|
||||
`DealDbContextModelSnapshot.cs`) — `dotnet ef migrations add GlobalSettings --context DealDbContext`.
|
||||
|
||||
Секреты хранятся тем же механизмом, что и секреты настроек: `apiHash` — AES-256-GCM (`enc:`),
|
||||
через существующий `ISecretCipher`/`AesGcmSecretCipher`.
|
||||
|
||||
### 2. Операторские ручки
|
||||
|
||||
`Deal.Api/Endpoints/OperatorSettingsEndpoints.cs`, группа `/api/operator/settings` (тег `operator-settings`):
|
||||
|
||||
| Метод / путь | Ответ | Коды |
|
||||
|---|---|---|
|
||||
| `GET /api/operator/settings/telegram-keys` | `{apiId, apiHash, keysSet}` — `apiId` открыт, `apiHash` — маска | 200, 401 |
|
||||
| `PUT /api/operator/settings/telegram-keys` `{apiId, apiHash}` | та же маска-форма после сохранения | 200, 400, 401 |
|
||||
|
||||
- Валидация: `api_id` — ровно 5–9 ASCII-цифр; `api_hash` — непустой, без маски (`…`) и без префикса `enc:`.
|
||||
- Ошибки: `400 {detail}` («api_id должен состоять из 5–9 цифр» / «Укажите непустой api_hash»),
|
||||
пустое тело — 400; `401 {detail:"Требуется вход оператора"}`.
|
||||
- Аудит: `telegram_keys_changed` (actor `operator`, `tenantId: null`, детали `{apiId, apiHashSet}`
|
||||
без секрета) — константа в `AuditEvents`.
|
||||
- Запрос `Endpoints/RequestModels/OperatorTelegramKeysRequest.cs`, DTO
|
||||
`Telegram/TelegramKeysMaskedDto.cs`, маппинг в `Program.cs`.
|
||||
|
||||
### 3. Ядро читает глобальные ключи
|
||||
|
||||
- `TelegramKeysService` переведён с `ISettingsStore` (тенант) на `IGlobalSettingsStore` +
|
||||
`GlobalSettingsKeys.TelegramKeys`; добавлены `GetMaskedAsync` и `SaveAsync` (валидация + шифрование),
|
||||
сохранён `GetAsync` (расшифрованный снимок для команд входа).
|
||||
- `TgStatusService` и `/api/tg/start-phone|start-qr` работают от глобальных ключей; при отсутствии —
|
||||
`400 {detail:"Ключи Telegram не заданы оператором"}` (без падения), а `GET /api/tg/status` отдаёт
|
||||
`keysSet:false`.
|
||||
- `TelegramKeysValue` — внутренняя (БД) форма значения `{apiId, apiHash:"enc:…"}`.
|
||||
|
||||
### 4. Настройки тенанта
|
||||
|
||||
`tgKeys` удалён из: `SettingsService.PublicForms` (`ToPublic`/`Mask`), `TgKeysPublicDto`,
|
||||
`TgKeysSetting`, `SettingKind.TgKeys`, `SettingsDefaults.TgKeys`, `SettingsKeys.TgKeys` + каталога
|
||||
`PublicKeys`, `SettingsService.PatchSecrets` (`ApplyTgKeysKey`), `SettingsService.ReadMerge`
|
||||
(`MergeTgKeys`), `SettingsService` (случай `switch`, константы `ApiId*`/`ApiHashMinLength`),
|
||||
`PublicSettingsDto.TgKeys`. Публичный каталог — 43 ключа (было 44). Данные тестовые — миграция
|
||||
данных не делалась. Вкладка/статус Telegram у тенанта остаются (подключение аккаунта).
|
||||
|
||||
### 5. Proto-комментарии
|
||||
|
||||
`src/contracts/telegram.proto`: «настройка tgKeys тенанта» → «глобальные ключи, задаёт оператор»
|
||||
(`StartPhone`/`StartPhoneRequest`), ошибка без ключей → 400 «Ключи Telegram не заданы оператором».
|
||||
Аналогично обновлены XML-doc `ITelegramGateway`.
|
||||
|
||||
### 6. Тесты
|
||||
|
||||
- `FakeGlobalSettingsStore` (in-memory `IGlobalSettingsStore`).
|
||||
- `TelegramKeysServiceTests` (14): шифрование `apiHash` (`enc:`, секрет не в БД), чтение
|
||||
расшифрованного снимка, маскирование ответа (в т.ч. короткий секрет — «s…»), валидация api_id/hash,
|
||||
битый JSON/шифротекст.
|
||||
- `GlobalSettingsStoreTests` (3, EF InMemory): отсутствующий ключ → null, upsert, UTC-`UpdatedAt`.
|
||||
- `OperatorSettingsEndpointsHttpTests` (6): 401 без сессии, пустая форма, сохранение+маска+аудит,
|
||||
чтение после PUT, 400 по api_id/api_hash.
|
||||
- `OperatorAuthHttpHost`: регистрация `IGlobalSettingsStore`/`ISecretCipher`/`TelegramKeysService`,
|
||||
маппинг `MapOperatorSettingsEndpoints`, новый вход `RunWithGlobalSettingsAsync`.
|
||||
- Обновлены `TgStatusServiceTests` (глобальные ключи), `SettingsCatalogTests` (43 ключа, убран
|
||||
`Defaults_TgKeysAreEmpty`), `SettingsServiceTests` (убраны tgKeys-кейсы).
|
||||
|
||||
### 7. Контракт для фронта
|
||||
|
||||
`docs/architecture/2026-09-10-operator-analytics-contract.md` — раздел «Операторские настройки:
|
||||
глобальные ключи Telegram» (обе формы и коды) + событие `telegram_keys_changed` в каталоге.
|
||||
`docs/api/api-map.md` §3.4/§4.6 — убран `tgKeys` из настроек тенанта и добавлена ссылка на операторский
|
||||
контракт.
|
||||
|
||||
## Формы ручек (для фронта)
|
||||
|
||||
```jsonc
|
||||
// GET /api/operator/settings/telegram-keys → 200
|
||||
{ "apiId": "1234567", "apiHash": "abcd…mnop", "keysSet": true }
|
||||
|
||||
// PUT /api/operator/settings/telegram-keys
|
||||
{ "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // apiId: 5–9 цифр, apiHash непустой
|
||||
// → 200 (та же маска-форма); 400 {detail}; 401 {detail:"Требуется вход оператора"}
|
||||
```
|
||||
|
||||
## Миграция
|
||||
|
||||
- Создана: `20260910194443_GlobalSettings` (`--context DealDbContext`, каталог `Migrations/`,
|
||||
как у существующих системных миграций).
|
||||
- **Не применена** к БД: dev-Postgres (`127.0.0.1:5433`) в этой сессии не поднят — `dotnet ef
|
||||
database update` вернул «Failed to connect». Применить при поднятом Postgres:
|
||||
`dotnet ef database update --context DealDbContext --project Deal.Infrastructure --startup-project Deal.Api`.
|
||||
|
||||
## Итог проверки
|
||||
|
||||
- `dotnet build Deal.sln -v q --nologo` — **0/0**.
|
||||
- `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — **1271/1271 PASS** (было 1245; +26).
|
||||
- Docker/Postgres не запускались; серверные/фоновые процессы не оставлялись.
|
||||
|
||||
## Осталось / заметки
|
||||
|
||||
- Применить системную миграцию на живой БД (см. выше) — Postgres не был доступен.
|
||||
- Фронтенд (отдельный агент): убрать `tgKeys` с вкладки настроек; UI оператора — по разделу
|
||||
операторского контракта.
|
||||
- `telegram-service` не менялся: ключи по-прежнему приходят в теле gRPC-запросов (источник ядра —
|
||||
глобальное хранилище).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Task — Добивка по ТЗ: бэкенд core (пункты 1–5)
|
||||
|
||||
Дата: 2026-09-10. Область: только `src/core` (+ один контрактный док и этот ledger).
|
||||
Итог: `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений; core-тесты **1245/1245**
|
||||
(было 1203, +42 новых).
|
||||
|
||||
Проверка качества: код-стайл 1 тип = 1 файл, XML-doc на public, явные `public` у членов интерфейсов,
|
||||
русские комментарии, именованные константы вместо магических чисел, `DateTimeOffset` UTC, camelCase JSON,
|
||||
`TreatWarningsAsErrors` (сборка с `-warnaserror` зелёная), порт/адаптер (интерфейс в модуле, EF-адаптер
|
||||
в Infrastructure).
|
||||
|
||||
---
|
||||
|
||||
## 1. ML-проверка на канале/сообщении (§8 ML, E11)
|
||||
|
||||
Было: `POST /api/ml/candidates` возвращал `{items: []}`, `POST /api/ml/apply` — всегда 404.
|
||||
Стало: рабочий сервис ручной проверки/разметки.
|
||||
|
||||
- **Новый сервис** `Deal.Modules.Pipeline/Application/MlReviewService.cs`.
|
||||
- `CandidatesAsync(dialogId, limit, ct)`: объединяет реальные сообщения из **очереди** (`IPipelineStore.ListAsync`),
|
||||
**отсева** (`ListPageAsync`) и **карточек** (`ICardStore.ListCardsAsync`) по ключу (dialogId, msgId);
|
||||
приоритет вердикта: card > rejected > queued. `dialogId` пустой — выборка по всем источникам;
|
||||
`limit` клампится 1..60 (как прототип). Каждый кандидат несёт исходный текст (≤600), время, `lead`,
|
||||
текущий `verdict` (+ `col`/`stage`/`reason`) и мнение ML (`IMlClient.PredictAsync`, сбой → `pred:null`).
|
||||
- `ApplyAsync(dialogId, msgId, action, ct)`: `skip` (без обучения) / `spam` / `board:<id>`.
|
||||
Обучение и переносы — **через существующие сервисы без дублей**:
|
||||
`CardsService.TrashCardAsync(teach:true)` и `CardsService.MoveDashboardCardAsync` (они сами шлют
|
||||
обучающий сигнал), `PipelineProcessingService.RejectAsync` + снятие строки очереди, `IMlClient.PushAsync`
|
||||
только там, где карточки нет. Неизвестная доска/действие → 400; сообщения нет ни в одном источнике → 404.
|
||||
- **Новый порт-метод** `ICardStore.GetCardBySourceAsync(dialogId, msgId, ct)` (+ EF-адаптер `KanbanStore.Cards.cs`,
|
||||
+ `FakeKanjStore`): поиск карточки по исходному сообщению (для `lead`/переносов).
|
||||
- **Эндпоинты** `Deal.Api/Endpoints/MlEndpoints.cs`: `candidates`/`apply` переведены на `MlReviewService`.
|
||||
- **Новые DTO**: `MlCandidateDto`, `MlCandidatePredictionDto`, `MlApplyResult`
|
||||
(`Deal.Modules.Pipeline/Application/Models`).
|
||||
- **DI**: `MlReviewService` зарегистрирован scoped в `PipelineModuleRegistrar`.
|
||||
- **Тесты**: `MlReviewServiceTests` — 11 (объединение источников, фильтр по каналу, кламп, skip/spam
|
||||
с картой и из очереди, board-перенос, неизвестная доска/действие, 404).
|
||||
|
||||
### Контракт (frontend) — см. `docs/architecture/2026-09-10-unified-api-contract.md`, раздел ML
|
||||
|
||||
`POST /api/ml/candidates` `{dialogId, limit}` →
|
||||
```json
|
||||
{ "items": [ { "id": 12345, "dialogId": "d_...", "text": "...", "time": 1757500000000,
|
||||
"lead": true, "verdict": "card", "col": "b_...", "stage": null, "reason": null,
|
||||
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } } ] }
|
||||
```
|
||||
`id` — id исходного сообщения (он же `msgId` для apply). `verdict`: `card`|`rejected`|`queued`.
|
||||
|
||||
`POST /api/ml/apply` `{dialogId, msgId, action}` → `{ok, learned, moved, leadId}`
|
||||
(`action`: `skip`|`spam`|`board:<id>`; `moved`: `trash`|id колонки|null). 404/400 — как раньше по форме
|
||||
`{detail}`.
|
||||
|
||||
> `MLPanel.vue` менять не требуется: он уже читает `items[].{id,dialogId,text,lead,pred}` и шлёт
|
||||
> `{dialogId,msgId:item.id,action}`, а `applyOne` использует `r.moved` — контракт совместим 1:1 с прототипом.
|
||||
|
||||
## 2. Глобальные исключения — стоп-уровень до ML/ИИ (§5.14/§8)
|
||||
|
||||
- **Настройки** (каталог/дефолты/PATCH/GET): `excludeKeywords` (List), `excludeLocations` (List),
|
||||
`excludeTypes` (List: `vacancy`/`freelance`/`announcement`), `excludeBudgetFrom`/`excludeBudgetTo` (Int;
|
||||
0 = не задано). Все опциональные; пустые по умолчанию. Затронуты `SettingsKeys`, `SettingsDefaults`,
|
||||
`PublicSettingsDto`, `SettingsService.BuildSnapshot`.
|
||||
- **Чистое ядро** `Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs` (+ `GlobalExcludeSettings`,
|
||||
`GlobalExclusionResult`): порядок — слова/технологии → локация/язык → тип (синонимы `TypeAliases`) →
|
||||
бюджет (числовые суммы `AmountParser`, без конвертации). Возвращает код правила, причину и терм.
|
||||
- **Применение**: в `PipelineWorkerService.PumpNewPassAsync` сразу после этапа-1 правил и **до дедупа/ML/ИИ**
|
||||
(токены не тратятся); отсев пишется `source=stop` с новыми этапами `exclude_kw`/`exclude_location`/
|
||||
`exclude_type`/`exclude_budget` (подписи добавлены в `PipelineRejectConstants.StageLabels`).
|
||||
- **Per-column exclude не тронут** (`ColumnExclusions` — вето размещения в колонку, как было).
|
||||
- **Тесты**: `GlobalExclusionRulesTests` (8), 2 интеграционных в `PipelineWorkerServiceTests`
|
||||
(слово-исключение и бюджет — отсев до ML, `PredictCalls==0`), 2 в `SettingsServiceTests`
|
||||
(round-trip PATCH/GET, дефолты), каталог `SettingsCatalogTests` обновлён (44 публичных ключа).
|
||||
|
||||
## 3. Группы фильтров колонки (§6.3)
|
||||
|
||||
- `ContainerRulesDto` расширен **в конец с дефолтами**: `levels`, `locations`, `types` (`IReadOnlyList<string>?`)
|
||||
и `prices` (`BudgetRangeDto?`) — старые позиционные вызовы и сохранённый `RulesJson` обратно совместимы.
|
||||
- `IContainerRules` дополнен `Levels/Locations/Types/Prices`.
|
||||
- Движок: `ColumnMatcher` (группы в `MatchText`/`ScoreText`/`HasActiveRules`; `levels` — через
|
||||
`GradeAliases`, `types` — через новый `TypeAliases`, `prices` — диапазон как `budget`), `MatchHitBuilder`
|
||||
(новые метки `Уровень`/`Локация`/`Тип`/`Цена`), `RulesDescriber` (описание групп), `ContainersEndpoints.NormalizeWireRules`.
|
||||
- **Тесты**: `ColumnRulesNewGroupsTests` — 9 (матчинг, алиасы, диапазон цены, has-active, `matchHits`,
|
||||
`describe`, camelCase-сериализация и обратная совместимость старого JSON). Существующие тесты правил зелёные.
|
||||
|
||||
## 4. Глубины очередей в `/api/operator/health` (§10.2)
|
||||
|
||||
- **Новый общий сборщик** `Deal.Api/Observability/RuntimeDepthsCollector` (+ `RuntimeDepthsDto`): обходит реестр
|
||||
тенантов (scope на тенант) и считает существующими сервисами/портами — `PipelineProcessingService.QueueCountsAsync`
|
||||
(очередь), `IMlLearningStore.CountOutboxAsync` (ML-outbox), `public.sessions`+`operator_sessions` (сессии).
|
||||
SQL не дублируется.
|
||||
- `DealMetricsCollector` переведён на этот сборщик (поведение метрик сохранено), регистрация singleton в `Program.cs`.
|
||||
- `GET /api/operator/health` теперь отдаёт `queues:{pipeline, mlOutbox}` и `sessions:{active}` (сбор — параллельно
|
||||
с пробой БД/сервисов; сбои секций мягко дают 0, ручка всегда 200).
|
||||
- **Тесты**: `RuntimeDepthsCollectorTests` — 2 (агрегат по двум тенантам; пустой реестр),
|
||||
`OperatorHealthEndpointsHttpTests` дополнен проверкой новых полей.
|
||||
|
||||
## 5. Детектор подозрительной активности (§10.5)
|
||||
|
||||
- **Новый сервис** `Deal.Modules.Tenants/Application/SuspiciousActivityService` (+ `SuspiciousFindingDto`,
|
||||
`SuspiciousActivityDto`): анализ аудита за окно (по умолчанию 24 ч, ≤500 записей) по правилам с
|
||||
**именованными порогами**: `failed_logins_per_ip` (10), `failed_logins_per_login` (5),
|
||||
`many_ips_per_actor` (5), `auth_failures_per_tenant` (20; косвенно серия 401/429). Уровень `high` при 2× порога.
|
||||
Зарегистрирован в `AddTenantsModule`.
|
||||
- **Операторский эндпоинт** `GET /api/operator/analytics/suspicious?from=&to=` (в `OperatorAnalyticsEndpoints`):
|
||||
`{from, to, scanned, truncated, items:[{kind, severity, subject, count, detail}]}`, только под операторской сессией.
|
||||
- **Тесты**: `SuspiciousActivityServiceTests` — 7 (пусто, пороги IP/логина, high-уровень, много IP, тенант,
|
||||
окно), HTTP — 401 во всех ручках + позитивный `Suspicious_ReturnsFindingsForFailedLoginBurst`.
|
||||
|
||||
---
|
||||
|
||||
## Контрактные изменения (сводка для фронта)
|
||||
|
||||
| Ручка/поле | Изменение |
|
||||
|---|---|
|
||||
| `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 — неизвестная доска/действие |
|
||||
| `GET /api/operator/health` | + `queues:{pipeline,mlOutbox}`, `sessions:{active}` |
|
||||
| `GET /api/operator/analytics/suspicious` | Новый эндпоинт (находки по логам безопасности) |
|
||||
| `GET/PATCH /api/settings` | + `excludeKeywords`, `excludeLocations`, `excludeTypes`, `excludeBudgetFrom`, `excludeBudgetTo` |
|
||||
| `rules` контейнера (GET/POST/PATCH) | + группы `levels`, `locations`, `types`, `prices` (старые поля и JSON совместимы) |
|
||||
| `matchHits` | + метки `Уровень`, `Локация`, `Тип`, `Цена` |
|
||||
|
||||
`MLPanel.vue` совместим без правок; для настроек исключений и новых групп колонки фронту нужно добавить UI.
|
||||
|
||||
## Что осталось (вне области этой задачи, `src/core`)
|
||||
|
||||
- Фронт: UI глобальных исключений (§5.14), групп колонки `levels/locations/types/prices` (§6.3),
|
||||
«открыть исходник» на карточке (§6.6), переключатель языка/i18n (§11.12).
|
||||
- §4.1/§8.1: `api_id/api_hash` — глобальная операторская настройка (сейчас ключи тенанта `tgKeys`).
|
||||
- §11.6 Cloudflare, живые интеграции (Telegram/LLM), k8s — вне кода ядра.
|
||||
- Миграции БД не добавлялись: новые данные хранятся в существующих KV-настройках и JSON-полях, схема не менялась.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Task — Добивка по ТЗ: фронтенд (UI-части)
|
||||
|
||||
Дата: 2026-09-10. Область: только `src/frontend` (+ этот ledger/отчёт). Без новых зависимостей.
|
||||
Итог: `npm run build` — зелёный; `npm run lint:i18n` — зелёный. Светлая и тёмная темы сохранены.
|
||||
|
||||
Бэкенд-контракты закрыты ранее (`task-tz-backend-report.md`); фронт сверен с фактическими формами
|
||||
`src/core/Deal.Api/Endpoints/*` и `PublicSettingsDto`/`ContainerRulesDto`, ничего не выдумано.
|
||||
|
||||
---
|
||||
|
||||
## 1. «Открыть исходник» как быстрое действие карточки (§6.6)
|
||||
|
||||
Файл: `src/frontend/src/components/card/Card.vue`.
|
||||
|
||||
- Добавлена быстрая кнопка-ссылка в футере дашбордной карточки (рядом с комментарием/корзиной/переносом,
|
||||
левее комментария), только в режиме `dashboard` (не в «Выбранных»).
|
||||
- Ссылка строится существующим хелпером `tgSourceUrl(card)` (`channel.handle` → `t.me/<handle>/<msg>`,
|
||||
иначе приватный `t.me/c/<peer>/<msg>` по `sourceDialogId`+`sourceMsgId`). Если данных для ссылки нет —
|
||||
действие недоступно (кнопка не рендерится). Клик не открывает карточку (`@click.stop`), цель `_blank`.
|
||||
- Строка подписи переиспользует существующий ключ `drawer.otkryt-ishodnoe-soobshchenie-v-telegram`; иконка
|
||||
`external` — из общего `Icon.vue`.
|
||||
|
||||
## 2. Глобальные исключения в настройках (§5.14/§8)
|
||||
|
||||
Файлы: `src/frontend/src/store/core.js`, `src/frontend/src/store/settings.js`,
|
||||
`src/frontend/src/components/settings/StopTab.vue` (вкладка «Фильтры входящих»).
|
||||
|
||||
- В `state` добавлены `excludeKeywords`, `excludeLocations`, `excludeTypes` (массивы),
|
||||
`excludeBudgetFrom`, `excludeBudgetTo` (int, 0 = не задано); `applySettings` применяет их с сервера.
|
||||
- Действия store: `addExcludeTerm`/`removeExcludeTerm` (нормализация trim+lower, как у стоп-фраз),
|
||||
`toggleExcludeType`, `persistExclusions` — одним PATCH `/api/settings` со всеми пятью ключами.
|
||||
- UI-блок «Глобальные исключения»: тег-списки для ключевых слов и локаций (`TagInput`, `danger`),
|
||||
чипы типов (вакансия/фриланс/объявление — теги `vacancy`/`freelance`/`announcement`), диапазон «от/до»
|
||||
для бюджета и кнопка «Сохранить». Все поля опциональны; пустые = исключение не срабатывает.
|
||||
- Строки — в словаре (`settings.globalnye-isklyucheniya*`, `settings.isklyucheniya-*`).
|
||||
|
||||
## 3. Новые группы фильтров колонки (§6.3)
|
||||
|
||||
Файл: `src/frontend/src/components/BoardRulesDialog.vue`.
|
||||
|
||||
- `form`/`load()`/`buildRules()`/`clearRules()` расширены группами `levels`, `locations`, `types`, `prices`.
|
||||
- UI: `levels` («Уровень») и `locations` («Локация / язык») — группы-теги рядом с keywords/stack/grade;
|
||||
`types` — фиксированные чипы (вакансия/фриланс/объявление); `prices` — второй диапазон «Ограничить ценой»
|
||||
с той же разметкой, что `budget` (обе секции рендерит общий цикл `RANGES`, без дублей).
|
||||
- Сборка правил: `buildRules` всегда кладёт `levels`/`locations`/`types` (пустые массивы — группа неактивна),
|
||||
`budget`/`prices` — только при заданных границах. Сохранение — прежним `saveBoardForm` (PATCH/POST
|
||||
`/api/containers`), форма ответа совместима.
|
||||
- **Попутный фикс:** счётчик значений группы выводил сломанный литерал (`{{ ... }} < 5 ? 'значения' : ...`;
|
||||
ключ `columns.form-g-key-length-1-znachenie-form-g-key`). Заменён на функцию `countWord` и три ключа
|
||||
`columns.znachenie/znacheniya/znachenij` (формы выбираются в коде — плюрализация в i18n не вводилась).
|
||||
Это было видно на экране как «3 {{ form[g.key].length === 1 ? …» и затрагивало в т.ч. новые группы.
|
||||
- Подсказка диалога (`columns.kolonka-eto-nabor-opcionalnyh-filtrov`) дополнена перечислением новых групп.
|
||||
|
||||
## 4. Оператор-консоль (новое в health и analytics)
|
||||
|
||||
Файлы: `src/frontend/src/store/operator.js`, `src/frontend/src/views/operator/HealthSection.vue`,
|
||||
`src/frontend/src/views/operator/AnalyticsSection.vue`.
|
||||
|
||||
- **Состояние (§10.2).** Новая карточка «Очереди и сессии»: `queues.pipeline`, `queues.mlOutbox`,
|
||||
`sessions.active` (плитки `StatCard`; тон `brand` при ненулевом значении).
|
||||
- **Подозрительная активность (§10.5).** В «Аналитике» — четвёртый подраздел «Подозрительная активность»
|
||||
(`GET /api/operator/analytics/suspicious?from=&to=`, общий период с обзором/токенами). Показывает
|
||||
число разобранных записей, флаг `truncated` и список находок `{kind, severity, subject, count, detail}`
|
||||
с бейджем уровня (`high`→danger, `medium`→warn) и человекочитаемым названием правила.
|
||||
- Лоадер `loadSuspicious` добавлен по образцу `loadOverview`/`loadTokens`.
|
||||
|
||||
---
|
||||
|
||||
## Проверка
|
||||
|
||||
- `npm run build` — ✓ (`vite build`, 110 модулей).
|
||||
- `npm run lint:i18n` — ✓ (кириллических пользовательских строк вне словарей нет).
|
||||
- Процессы не запускались; `netstat :5173` — порт свободен.
|
||||
|
||||
## Найденные расхождения / замечания
|
||||
|
||||
1. **Недокументированный эндпоинт `suspicious`.** `GET /api/operator/analytics/suspicious` есть в коде
|
||||
(`OperatorAnalyticsEndpoints`) и в отчёте бэкенда, но отсутствует в `docs/architecture/2026-09-10-unified-api-contract.md`
|
||||
(там описан только `health`). Форма ответа взята из `SuspiciousActivityDto`. Док не правил (контракт менять нельзя).
|
||||
2. **`ContainersService.NormalizeRules` (core, вне области) не учитывает новые группы** при решении
|
||||
«правил нет»: проверяются только `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`. На фронте не
|
||||
воспроизводится (UI всегда шлёт непустой `mode` = `all`/`any`), но латентно: правила только из
|
||||
`levels/locations/types/prices` с пустым `mode` будут обнулены. Код core не менялся — фиксирую как находку.
|
||||
3. **Слепое пятно `scripts/i18n-lint.mjs`.** Незакрытый `<` в текстовом узле шаблона переводит остаток строки
|
||||
в «псевдотег», и кириллица после него не проверяется (именно так долго существовал сломанный счётчик
|
||||
в `BoardRulesDialog.vue`). Линтер не трогал; фикс UI выполнен, но сам пробел остаётся (кандидат на доработку
|
||||
скрипта отдельной задачей).
|
||||
4. **`grade` и `levels` семантически близки** («Грейд / уровень» и новый «Уровень»). Бэкенд держит их
|
||||
отдельными группами — UI оставлен 1:1 с контрактом (ярлыки уточнены), но стоит подтвердить у владельца,
|
||||
не дублируют ли они друг друга в его сценарии.
|
||||
5. **Типы заявок.** Для исключений/фильтров используются теги бэкенда `vacancy`/`freelance`/`announcement`
|
||||
(у `wantedType` в настройках — `both/vacancy/freelance`); сопоставление по смыслу выполнено в UI (чипы),
|
||||
значения не смешиваются.
|
||||
|
||||
## Что осталось (вне области этой задачи)
|
||||
|
||||
- §11.12 переключатель языка (i18n архитектурно готов, UI-переключателя нет — решение владельца: только ru).
|
||||
- §4.1/§8.1 `api_id/api_hash` как глобальная операторская настройка (сейчас — ключи тенанта).
|
||||
- Живые интеграции Telegram/LLM, Cloudflare, k8s — вне кода фронта.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Task report — «Внешний вид» (§8.12 ТЗ): светлая тема + раздел настроек
|
||||
|
||||
## Что было
|
||||
Аудит ТЗ показал единственный полностью отсутствующий пункт — §8.12 «Внешний вид»: в настройках
|
||||
не было раздела оформления, тема поддерживалась только тёмная.
|
||||
|
||||
## Что сделано (только `src/frontend`, без новых зависимостей)
|
||||
|
||||
### 1. Светлая тема через токены (`src/frontend/src/style.css`)
|
||||
- Тёмная палитра осталась дефолтом в блоке `@theme` — вид 1:1.
|
||||
- Добавлен блок `:root[data-theme="light"]`, который переопределяет те же токены
|
||||
(`--color-ink/panel/raise/hover/edge/hi/mid/low/brand/brand-2/online/danger/warn`), а также
|
||||
`--shadow-card/--shadow-pop`, цвет скроллбара (`--scrollbar-thumb`), линии фоновой сетки
|
||||
(`--grid-line`), `color-scheme: light`.
|
||||
- Подобраны читаемые значения (напр. `ink #eef1f6`, `raise #fff`, `hi #131a26`, `mid #545e70`,
|
||||
`brand #5f57ea`, `online #16a34a`, `danger #dc2626`, `warn #b45309`).
|
||||
- Компоненты не переписывались — все утилиты (`bg-ink`, `text-hi`, …) ссылаются на токены.
|
||||
|
||||
### 2. Полупрозрачные слои `bg-white/N` / `border-white/N`
|
||||
- В светлой теме токен `--color-white` намеренно указывает на тёмный оттенок (`#0b1220`): белые
|
||||
подсветки/разделители/hover становятся мягкими серыми. Тёмная тема не затронута.
|
||||
- Литеральные `text-white`/`bg-white` (текст на бренд-градиенте, фон QR-кода, ползунок тумблера)
|
||||
сохранены белыми отдельными правилами.
|
||||
- Добавлены токены `--color-on-brand` / `--color-on-warn` (цвет текста поверх сплошной заливки),
|
||||
применены в бейджах счётчиков (`Card.vue`, `Sidebar.vue`) и кнопке напоминания
|
||||
(`HoldReminderDialog.vue`).
|
||||
- `.tgmd` (подложки code/pre/spoiler) получили светлые значения; скроллбар и линии сетки — через
|
||||
переменные. `accent-[#8b8ff8]` → `accent-brand` (4 места); убраны инлайн `style="color-scheme: dark"`
|
||||
(теперь наследуется от `html`, который переключается темой).
|
||||
|
||||
### 3. Раздел «Внешний вид» в настройках
|
||||
- Новая вкладка `appearance` в `SettingsView.vue` рядом с «Уведомлениями»; компонент
|
||||
`src/frontend/src/components/settings/AppearanceTab.vue`.
|
||||
- Три варианта: «Тёмная» (по умолчанию) / «Светлая» / «Системная». Переключение мгновенное, тост
|
||||
«Тема обновлена». Выбранный вариант визуально отмечен (рамка/галочка).
|
||||
- Новые иконки в `Icon.vue`: `palette`, `moon`, `sun`, `monitor`.
|
||||
|
||||
### 4. Хранение и отсутствие «мигания»
|
||||
- `src/frontend/src/composables/theme.js`: значения `dark|light|system`, ключ `localStorage` —
|
||||
`leadradar_theme`; `setTheme()` меняет `data-theme` на `<html>` и сохраняет выбор; режим `system`
|
||||
слушает `prefers-color-scheme`.
|
||||
- Инлайн-скрипт в `src/frontend/index.html` применяет тему **до первого рендера** (дублирует логику
|
||||
composable, ключ/значения совпадают) — вспышки тёмной темы нет.
|
||||
|
||||
### 5. Строки i18n
|
||||
Новые ключи в `src/i18n/locales/ru.js` (`settings.vneshnij-vid`, `settings.tema-oformleniya`,
|
||||
`settings.tema-oformleniya-opisanie`, `settings.tema-temnaya[-opisanie]`,
|
||||
`settings.tema-svetlaya[-opisanie]`, `settings.tema-sistemnaya[-opisanie]`, `settings.tema-primenena`).
|
||||
Хардкода кириллицы нет.
|
||||
|
||||
### 6. Документация (вне `src/frontend`, единственная правка)
|
||||
`docs/technical/Техническая-документация-Дейл.md` — новый раздел §15 «Темы оформления»: токены, как
|
||||
устроена светлая тема, полупрозрачные слои, где хранится выбор.
|
||||
|
||||
## Как проверял
|
||||
- `cd C:\telbase\src\frontend` → `npm run build` — **зелёно** (`✓ built`, 110 модулей; чанки
|
||||
vendor/i18n/index, без предупреждений о размере).
|
||||
- `npm run lint:i18n` — **зелёно** (`✓ кириллических пользовательских строк вне словарей не найдено`).
|
||||
- Проверил скомпилированный CSS: блок `:root[data-theme=light]` присутствует, утилиты `bg-white/N`
|
||||
по-прежнему используют `var(--color-white)` (значит, следуют теме), `.text-on-brand` сгенерирован.
|
||||
- Статический аудит цветов по всем экранам: помимо токенов встречаются только `bg-black/45–60`
|
||||
(затемнение модалок — уместно в обеих темах) и один `to-[#d97706]` (янтарный градиент). Прочих
|
||||
«серых/белых» хардкодов нет.
|
||||
- Диагностика IDE по новым/изменённым файлам — без ошибок (в `style.css` только унаследованные
|
||||
предупреждения про `@theme` и `line-clamp`).
|
||||
|
||||
Тёмная тема не менялась: все новые правила либо под `:root[data-theme="light"]`, либо заменяют
|
||||
токен на эквивалентное значение (`text-ink` на бренд-бейдже → `text-on-brand` == `#0a0d12` в тёмной;
|
||||
`accent-brand` == `#8b8ff8` в тёмной; удалённые `color-scheme: dark` наследуются от `html`).
|
||||
|
||||
## Как проверял визуально
|
||||
Автоматизированного браузерного прогона нет (нет соответствующих зависимостей, добавлять запрещено),
|
||||
поэтому проверка светлой темы — статический аудит токенов/утилит по основным экранам: дашборд/колонки/
|
||||
карточки, drawer, настройки (включая новую вкладку), оператор-консоль, страница активации, вход.
|
||||
Скриншотная проверка в браузере — на стороне владельца.
|
||||
|
||||
## Что осталось / на что обратить внимание
|
||||
- Полная визуальная приёмка светлой темы в браузере (руками): контрасты на реальных данных,
|
||||
пользовательские цвета колонок (задаются динамически, тема их не меняет).
|
||||
- Возможный фоллоу-ап: если понадобится ещё язык/тема — новых зависимостей не требуется,
|
||||
архитектура готова (словари + токены).
|
||||
@@ -0,0 +1,55 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage2-settings.md
|
||||
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
|
||||
|
||||
## Todos
|
||||
- Task 1: complete (review clean; 38 PASS; Decrypt без `enc:` → "" — приемлемо, legacy-данных нет). Отчёт: task-1-report.md.
|
||||
- [x] Task 1: Шифрование секретов (AES-GCM)
|
||||
- Task 2: complete (review clean; каталог ключей полный 1:1 с прототипом; 51 PASS). Отчёт: task-2-report.md.
|
||||
- [x] Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища
|
||||
- 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.
|
||||
- [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 (build 0/0; 88 PASS; dev-check на deal-postgres 24/24). Отчёт: task-4-report.md.
|
||||
- [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 (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-приёмка
|
||||
- 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 + запрет авто-редиректов.
|
||||
- [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 (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.
|
||||
- [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 (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.
|
||||
- [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 (review clean; 161 PASS; curl 31/31). Отчёт: task-9-report.md.
|
||||
- [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 (review clean; 175 PASS; curl 25/25). Отчёт: task-10-report.md.
|
||||
- [x] Task 10: Тестер фильтров — IncomingRules и /api/admin/check-message
|
||||
- Task 11: complete (review pending). Отчёт: 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 — прод-ужесточение позже.
|
||||
- [x] Task 11: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
## Pre-flight scan
|
||||
|
||||
| Пара | Производит / потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T1 → T3/T6 | ISecretCipher потребляется SettingsService/ai check | Чисто |
|
||||
| T2 → T3/T4 | каталог ключей + дефолты + ISettingsStore → сервис/адаптер | Чисто |
|
||||
| T3 → T5 | SettingsService → эндпоинты | Чисто |
|
||||
| T4 → T5 | DI адаптера | Чисто |
|
||||
| T8 → T2/T3 | ratesCache — внутренний KV-ключ через ISettingsStore | Чисто (внутренние ключи не публичны) |
|
||||
| T9 | Contracts/Integrations IMlClient — новый проект Contracts наполняется | Проверить ссылки (Contracts уже referenced) |
|
||||
| T10 | IncomingRules — переиспользуется этапом 4 | Чисто |
|
||||
| T6/T7 | HTTP наружу (ai/check), cbr (rates) | SSRF-риск: только baseUrl из настроек тенанта (allowlist провайдеров) — следить в ревью |
|
||||
| T2 | новые EF-таблицы не создаются | Таблица settings существует |
|
||||
|
||||
## Task status
|
||||
@@ -0,0 +1,99 @@
|
||||
# Task 1 — «Шифрование секретов (AES-GCM)» — отчёт
|
||||
|
||||
Статус: **complete**. Build 0 warnings / 0 errors; тесты 38/38 PASS (было 25, добавлено 13).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 1 L126–148, Ruling 2 L59–65).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы
|
||||
- `src/core/Deal.Modules.Settings/Application/ISecretCipher.cs` — порт в модуле Settings
|
||||
(namespace `Deal.Modules.Settings.Application`): `string Encrypt(string plainText)`,
|
||||
`string Decrypt(string cipherText)`. XML-doc фиксирует формат `enc:` + Base64(nonce ‖ ct ‖ tag)
|
||||
и контракт: Decrypt повреждённого/чужого значения → пустая строка **без исключений**.
|
||||
Модуль НЕ получил новых ссылок — интерфейс чистый (BCL). Grep по папке модуля:
|
||||
`Infrastructure|EntityFramework|Npgsql` → **0 совпадений** (модуль остался чистым).
|
||||
- `src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs` — `public sealed`, реализует
|
||||
`ISecretCipher`. AES-256-GCM (`System.Security.Cryptography.AesGcm`), ключ 32 байта передаётся
|
||||
в конструктор; именованные константы: `NonceSizeBytes = 12`, `TagSizeBytes = 16`,
|
||||
`KeySizeBytes = 32`, `EncryptedPrefix = "enc:"`.
|
||||
- `src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs` — `public sealed`, разрешает
|
||||
ключ по Ruling 2, кэширует после первого разрешения (`GetKey()`), ctor принимает ContentRoot.
|
||||
- `src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs` — 13 тест-кейсов (см. ниже).
|
||||
- `.superpowers/sdd/deal-stage2-settings/task-1-report.md` — этот отчёт.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
|
||||
`Deal.Modules.Settings` (по образцу ссылки на `Deal.Modules.Tenants` — реализация порта модуля).
|
||||
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` — добавлен метод
|
||||
`AddDealSecurity(this IServiceCollection services, string contentRootPath)`.
|
||||
- `src/core/Deal.Api/Program.cs` — вызов `builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);`
|
||||
рядом с `AddDealPersistence()` (как Program.cs регистрирует Infrastructure).
|
||||
|
||||
## Решения
|
||||
|
||||
### DI: отдельный `AddDealSecurity`, а не `AddDealPersistence`
|
||||
`AddDealPersistence` — регистрация scoped-EF-адаптеров (порт/адаптер персистентности), а шифрование —
|
||||
не персистентность. Добавлен отдельный метод в тот же статический класс `ServiceCollectionExtensions`
|
||||
(Infrastructure, точка регистрации как у `AddDealPersistence`), вызывается в `Program.cs` той же строкой-соседом.
|
||||
`ISecretCipher` регистрируется **singleton** (реализация без разделяемого состояния — потокобезопасна).
|
||||
|
||||
Сигнатура `(IServiceCollection, string contentRootPath)` вместо предложенной в ТЗ
|
||||
`(IServiceCollection, IConfiguration)`: провайдеру нужен ContentRoot (каталог файла-ключа), которого
|
||||
в `IConfiguration` нет; env-переменные (`DEAL_ENCRYPTION_KEY`, `DEAL_ENCRYPTION_KEY_FILE`) провайдер
|
||||
читает из окружения напрямую — они не проходят через секции конфигурации.
|
||||
|
||||
### EncryptionKeyProvider — вне DI (обоснование)
|
||||
Ключ разрешается **один раз при вызове `AddDealSecurity`** (на старте приложения) и сразу передаётся
|
||||
в конструктор `AesGcmSecretCipher`; провайдер после этого рантайм-сервисам не нужен. Преимущества:
|
||||
(а) невалидный env-ключ останавливает запуск (план Task 1: «невалидный env-ключ → исключение при
|
||||
старте», семантика `crypto._get_fernet`); (б) контейнер не хранит лишнего состояния и зависимостей.
|
||||
Порядок разрешения — как в `crypto.py L22–42`: env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64,
|
||||
декодирование терпимо к urlsafe-алфавиту и отсутствию padding) → иначе файл
|
||||
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`); при первом
|
||||
старте файл генерируется (`RandomNumberGenerator`, 32 случайных байта, запись urlsafe-Base64).
|
||||
Ошибки файла/окружения оборачиваются в `InvalidOperationException` с понятным сообщением.
|
||||
|
||||
### `.NET 10` AesGcm API (зафиксировано)
|
||||
Использован конструктор `new AesGcm(key, tagSizeInBytes)` — явный размер тега 16 (без неявного
|
||||
дефолта, который в .NET 8+ помечен SYSLIB0053). Nonce — 12 байт, генерируется
|
||||
`RandomNumberGenerator.GetBytes`. One-shot-вызовы `Encrypt(nonce, plain, cipher, tag)` /
|
||||
`Decrypt(nonce, cipher, tag, plain)` со спан-слайсами payload'а; повреждённый tag →
|
||||
`AuthenticationTagMismatchException` (подкласс `CryptographicException`) → возврат `""`.
|
||||
Экземпляр `AesGcm` создаётся на операцию (операции с секретами редкие; отсутствие разделяемого
|
||||
состояния снимает вопросы потокобезопасности singleton).
|
||||
|
||||
### Тесты (`SecretCipherTests`, фиксированный 32-байтовый ключ 1..32)
|
||||
1. Roundtrip Encrypt→Decrypt для кириллицы, спецсимволов, URL и пустой строки (4 кейса) — исходная строка.
|
||||
2. `Encrypt("")` → `""`.
|
||||
3. Непустой Encrypt даёт токен с префиксом `enc:`.
|
||||
4. Два Encrypt одной строки — разные токены (случайный nonce).
|
||||
5. Decrypt строки без префикса `enc:` → `""`.
|
||||
6. Decrypt мусора: `enc:`, `enc:не-base64!`, payload короче nonce+tag (`enc:AAAA`) → `""` без исключений.
|
||||
7. Decrypt токена с повреждённым последним байтом тега → `""`.
|
||||
8. Decrypt токена, зашифрованного чужим ключом → `""`.
|
||||
|
||||
## Проверки (выводы)
|
||||
```
|
||||
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (все 10 проектов)
|
||||
dotnet test tests/Deal.Tests.Unit --no-build
|
||||
Сводка теста: всего: 38; сбой: 0; успешно: 38; пропущено: 0 (было 25 → +13)
|
||||
```
|
||||
|
||||
## Отклонения от кода плана
|
||||
1. **Decrypt строки без префикса `enc:` возвращает `""`, а не «как есть».** План Task 1
|
||||
(L130) и `crypto.decrypt_text` (L55–56) предписывают passthrough незашифрованных значений
|
||||
«ранних версий». ТЗ задачи (список тестов, п. 5) явно требует обратное: «Decrypt строки без
|
||||
префикса `enc:` → пустая строка». Реализовано по ТЗ: значение без префикса трактуется как
|
||||
«чужое» (Ruling 2) — в новой .NET-БД legacy-значений нет, все секреты пишутся через Encrypt.
|
||||
Если passthrough понадобится позже (миграция старых данных) — это однострочное изменение.
|
||||
2. **`MaybeEncrypt` и `EncryptionOptions.cs` из плана не создавались** — ТЗ задачи задаёт
|
||||
интерфейс только из `Encrypt`/`Decrypt`; пустые значения обрабатывает сам `Encrypt` (→ `""`,
|
||||
семантика `crypto.encrypt_text`). IOptions-секция для пути файла-ключа не нужна: путь задаётся
|
||||
env `DEAL_ENCRYPTION_KEY_FILE` + ContentRoot из конструктора.
|
||||
3. **Warning-лог при генерации файла-ключа** (Ruling 2, `crypto.py L40`) не выводится: ключ
|
||||
разрешается синхронно в `AddDealSecurity` до построения контейнера, где `ILogger` недоступен,
|
||||
а провайдер сознательно не регистрируется в DI. Упрощение осознанное; при появлении
|
||||
потребителя-сервиса warning можно добавить (провайдер остаётся вне DI).
|
||||
4. **Дополнительный тест** `Decrypt_TokenEncryptedWithAnotherKey_ReturnsEmptyString` добавлен сверх
|
||||
списка ТЗ (покрывает ветку «зашифровано чужим ключом» из Ruling 2 — тот же путь, что
|
||||
повреждённый tag).
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env sh
|
||||
# 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 →
|
||||
# дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → stage1.pass:true,
|
||||
# stage2.skipped:true, passed:true → пустой текст → stage1.pass:false «короче 24 символов» →
|
||||
# PATCH stopPhrases=[«взаимный пиар»], minLen=10 → текст со стоп-фразой → stage1.pass:false
|
||||
# «стоп-фраза «взаимный пиар»» → текст «Ищу работу python…» → stage1.pass:false
|
||||
# «резюме соискателя» (маркер «ищу работу»; stopPhrases уже не содержит «ищу работу») →
|
||||
# валидный текст → passed:true → logout → 401. Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
|
||||
STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
|
||||
RESUME_BODY="$SCRIPT_DIR/task-10-resume.json"
|
||||
PATCH_BODY="$SCRIPT_DIR/task-10-patch.json"
|
||||
JAR="/tmp/task10-jar.txt"
|
||||
OUT="/tmp/task10-out.txt"
|
||||
LOG="/tmp/task10-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. POST /api/admin/check-message без куки — ожидаем 401 =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 3. Дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → passed:true =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
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 "passed:true" '"passed":true'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"text":""}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "stage1 fail по длине" '"stage1":{"pass":false,"reason":"короче 24 символов"}'
|
||||
check "stage2 pass:false skipped:true" '"stage2":{"pass":false,"reason":null,"skipped":true}'
|
||||
check "passed:false" '"passed":false'
|
||||
|
||||
echo
|
||||
echo "== 5. PATCH обработки: stopPhrases=[«взаимный пиар»], minLen=10 =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]' '"stopPhrases":["взаимный пиар"]' '"minLen":10'
|
||||
|
||||
echo
|
||||
echo "== 6. Текст со стоп-фразой «взаимный пиар» → stage1.pass:false, причина = фраза =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "stage1 fail по стоп-фразе" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
|
||||
check "passed:false" '"passed":false'
|
||||
|
||||
echo
|
||||
echo "== 7. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → резюме =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "stage1 fail по резюме (маркер «ищу работу»)" '"pass":false,"reason":"резюме соискателя («ищу работу»)"'
|
||||
check "passed:false" '"passed":false'
|
||||
|
||||
echo
|
||||
echo "== 8. Валидный текст (после PATCH) → passed:true =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "stage1.pass:true" '"stage1":{"pass":true,"reason":null}'
|
||||
check "stage2 skipped pass:true" '"stage2":{"pass":true,"reason":null,"skipped":true}'
|
||||
check "passed:true" '"passed":true'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout check-message 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 10. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1 @@
|
||||
{"text": "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу"}
|
||||
@@ -0,0 +1 @@
|
||||
{"stopPhrases":["взаимный пиар"],"minLen":10}
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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`).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 10 L367–391, Ruling 4 L71–75, Ruling 8 L97–105; референс `pipeline.py` stage1_plain L94–124 + `_resume_reason` L644–658, `dashboard_routes.py` L267–284, api-map §3.2 L109/§4.10 L364; фронт `SettingsView.vue` L142–155/тестер-блок L1245–1272, `store.js` checkIncomingMessage L1722–1733).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `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 L620–623). |
|
||||
| `Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs` | create | Результат `{pass, reason, stage, kind, kw}` 1:1 pipeline.py L97–99; kind/kw — для тестера-мониторинга и этапа 4 (Ruling 8), наружу в /admin/check-message НЕ идут. |
|
||||
| `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` L273–284/§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 L72–73). |
|
||||
| `Deal.Api/Program.cs` | modify | `app.MapFilterTesterEndpoints()`. |
|
||||
| `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 проверок). |
|
||||
|
||||
## Границы и решения
|
||||
|
||||
- **kind/kw — только внутри IncomingRules**: план Task 10 L375 требует результат `{pass, reason, stage, kind, kw}`, но wire тестера (api-map §4.10 L364, dashboard_routes L273–284) и фронт (`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 L106–108), поэтому 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).
|
||||
- **Порядок правил фиксирован** (длина → стоп → резюме → тип), причины — фиксированные строки python; на проходе `kind/kw=""`, `reason=null`.
|
||||
- **ИИ-фильтр тестера не вызывается** (план Task 10 L377–380, требование задачи п.5): этап 6 — вне этапа; ветка «ошибка ИИ → skipped pass:true» прототипа (L281) здесь не нужна — ИИ не зовётся вовсе, поэтому «успех этапа 2» = `{pass:true, reason:null, skipped:true}` всегда.
|
||||
|
||||
## Тесты (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.
|
||||
|
||||
## Приёмка (curl :5080, admin/admin)
|
||||
|
||||
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).
|
||||
3. Пустой текст → 200 `stage1.pass:false, reason:"короче 24 символов"`, `stage2.pass:false skipped:true`, `passed:false`.
|
||||
4. PATCH `{"stopPhrases":["взаимный пиар"],"minLen":10}` → снимок `"stopPhrases":["взаимный пиар"],"minLen":10`.
|
||||
5. Текст со «взаимный пиар» → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false`.
|
||||
6. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → `reason:"резюме соискателя («ищу работу»)"`, `passed:false`.
|
||||
7. Валидный текст после PATCH → `passed:true`.
|
||||
8. logout → check-message 401. Итог: **PASS=25 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
|
||||
|
||||
## Concerns / замечания
|
||||
|
||||
1. **kind не в wire**: для наблюдаемости «какого правила сработало» на этапе 4 у IncomingRules есть kind/kw; если позже понадобится показывать kind в тестере — это будет расширение контракта §4.10 (сейчас 1:1 с прототипом, kind/kw не отдаём).
|
||||
2. **«Резюме»-семантика зависит от настроек пользователя**: пока в `stopPhrases` лежит «резюме»/«ищу работу» (дефолты), такие тексты режутся стоп-списком раньше resume-ветки — это поведение прототипа 1:1 (порядок правил L106–108), не баг.
|
||||
3. **Python-`casefold` ≈ .NET `ToLowerInvariant`** для кириллицы/латиницы; экзотика (`ß`→ss) не воспроизводится — для RU/EN-текстов каналов незначимо.
|
||||
4. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/wire-ветки эндпоинта покрыты curl-приёмкой; юнит-уровень — IncomingRules (правила) + wire-форма в curl.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
|
||||
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)
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"text": "Ищу работу python backend разработчик с опытом 5 лет, удалённая занятость, фриланс"}
|
||||
@@ -0,0 +1 @@
|
||||
{"text": "Заметил у вас отличный сервис и взаимный пиар в чатах, давайте продвигать каналы друг друга бесплатно"}
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 11 curl-приёмка этапа 2 «Дейл»: единый сквозной сценарий на :5080 (план Task 11 L393-409,
|
||||
# Self-Review L411-429). Порядок: 401 без куки → login admin/admin → GET /api/settings (дефолты)
|
||||
# → PATCH группы Tasks 5/7 (minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/
|
||||
# colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource) → GET сверка (секреты
|
||||
# замаскированы; ratesCache/mlDecisions/aiDecisions не публикуются) → psql (enc: в aiConfigs/tgKeys,
|
||||
# открытого ключа нет) → POST /api/rates/refresh + GET /api/rates (mock-кэш) →
|
||||
# POST /api/ai/check (ветка по настройкам: локальный ollama) → GET /api/ml/status (ready:false-
|
||||
# структура) + predict + reset → POST /api/admin/check-message (валидный → passed:true; текст со
|
||||
# стоп-фразой → отсев) → logout → 401 на GET /api/settings → очистка dev-БД. PASS/FAIL каждого шага.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
PATCH_BODY="$SCRIPT_DIR/task-11-patch.json"
|
||||
GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
|
||||
STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
|
||||
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
|
||||
JAR="/tmp/task11-jar.txt"
|
||||
OUT="/tmp/task11-out.txt"
|
||||
LOG="/tmp/task11-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
echo " ----- полный ответ -----"
|
||||
cat "$OUT"
|
||||
echo " ------------------------"
|
||||
fi
|
||||
}
|
||||
|
||||
check_absent() {
|
||||
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
|
||||
desc=$1
|
||||
pat=$2
|
||||
if grep -qF -- "$pat" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не должно присутствовать: $pat"
|
||||
echo " ----- полный ответ -----"
|
||||
cat "$OUT"
|
||||
echo " ------------------------"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc (отсутствует: $pat)"
|
||||
fi
|
||||
}
|
||||
|
||||
show() {
|
||||
# Короткий превью ответа в лог (полный ответ — в $OUT, печатается при FAIL)
|
||||
head -c 200 "$OUT"
|
||||
echo " …"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$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)
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/settings без куки — ожидаем 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
|
||||
show
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/settings — дефолтный снимок (чистая БД) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
show
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "int/bool-дефолты" '"minLen":24' '"archiveAfterDays":14' '"mlEnabled":true' '"aiEnabled":true'
|
||||
check "дефолтные stopPhrases (4)" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
|
||||
check "строковые дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
|
||||
check "colState пуст, tgKeys пусты" '"colState":{}' '"tgKeys":{"apiId":"","apiHashSet":false}'
|
||||
check "aiConfigs deepseek без ключа (маска)" '"deepseek":{"baseUrl":"https://api.deepseek.com","model":"deepseek-v4-flash","keySet":false,"keyMasked":""}'
|
||||
PROV_COUNT=$(grep -o '"id":"' "$OUT" | wc -l)
|
||||
if [ "$PROV_COUNT" = "7" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] providers — 7 провайдеров ($PROV_COUNT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] providers — ожидалось 7, найдено $PROV_COUNT"
|
||||
fi
|
||||
check_absent "в дефолтном снимке нет шифротекста enc:" 'enc:'
|
||||
check_absent "внутренние ключи не публикуются" 'ratesCache'
|
||||
check_absent "внутренние ключи не публикуются" 'mlDecisions'
|
||||
check_absent "внутренние ключи не публикуются" 'aiDecisions'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT"
|
||||
show
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "обработка: minLen/stopPhrases" '"minLen":30' '"stopPhrases":["взаимный пиар"]'
|
||||
check "хранение/уведомления" '"autoArchive":false' '"archiveAfterDays":7' '"remindersEnabled":false'
|
||||
check "colState passthrough" '"colState":{"review":1,"done":2}'
|
||||
check "валюта: rateSource mock" '"rateSource":"mock"'
|
||||
check "ИИ: активный провайдер ollama" '"aiProvider":"ollama"'
|
||||
check "aiConfigs: deepseek keySet+маска (без открытого ключа)" '"keySet":true,"keyMasked":"sk-1…90ab"'
|
||||
check "tgKeys: apiId + apiHashSet" '"tgKeys":{"apiId":"123456","apiHashSet":true}'
|
||||
check "myPrompts: 1 запись с id pp_" '"myPrompts":[{"id":"pp_'
|
||||
check_absent "открытого apiKey в ответе PATCH нет" 'sk-1234567890ab'
|
||||
check_absent "enc: в public-снимке нет" 'enc:'
|
||||
|
||||
echo
|
||||
echo "== 5. GET /api/settings — сверка: переопределения видны, секреты замаскированы =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
show
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "переопределения применены" '"minLen":30' '"archiveAfterDays":7' '"autoArchive":false' '"remindersEnabled":false'
|
||||
check "stopPhrases/colState новые" '"stopPhrases":["взаимный пиар"]' '"colState":{"review":1,"done":2}'
|
||||
check "rateSource/aiProvider новые" '"rateSource":"mock"' '"aiProvider":"ollama"'
|
||||
check "myPrompts сохранены" '"myPrompts":[{"id":"pp_'
|
||||
check "deepseek keySet+маска" '"keySet":true,"keyMasked":"sk-1…90ab"'
|
||||
check "tgKeys apiHashSet" '"apiId":"123456","apiHashSet":true'
|
||||
check_absent "открытого apiKey в GET нет" 'sk-1234567890ab'
|
||||
check_absent "enc: в GET-снимке нет (наружу только маски)" 'enc:'
|
||||
check_absent "внутренний ratesCache не публикуется" 'ratesCache'
|
||||
check_absent "внутренний mlDecisions не публикуется" 'mlDecisions'
|
||||
check_absent "внутренний aiDecisions не публикуется" 'aiDecisions'
|
||||
|
||||
echo
|
||||
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
|
||||
check "8 ожидаемых ключей-переопределений на месте" '8'
|
||||
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='aiConfigs';" > "$OUT" 2>/dev/null
|
||||
check "aiConfigs.ValueJson содержит enc: (ключ зашифрован)" 'enc:'
|
||||
check_absent "aiConfigs.ValueJson не содержит открытого ключа" 'sk-1234567890ab'
|
||||
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null
|
||||
check "tgKeys.ValueJson содержит enc: (apiHash зашифрован)" 'enc:'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "refresh 200 ok:true" '[HTTP:200]' '"ok":true' '"source":"mock"' '"base":"RUB"'
|
||||
|
||||
echo
|
||||
echo "== 7b. GET /api/rates — тот же mock-кэш (updatedAt на месте) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET rates 200, source mock" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
|
||||
check_absent "updatedAt не null после refresh" '"updatedAt":null'
|
||||
|
||||
echo
|
||||
echo "== 8. POST /api/ai/check — ветка по настройкам (активный провайдер ollama — локальный) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" \
|
||||
-H "Content-Type: application/json" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "ai/check 200 ok:true" '[HTTP:200]' '"ok":true' '"provider":"ollama"' '"local":true' '"name":"Ollama (локально)"'
|
||||
check "сообщение локального сервера" 'Локальный сервер «Ollama (локально)» (ping в проде)'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
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}'
|
||||
|
||||
echo
|
||||
echo "== 9b. POST /api/ml/predict с текстом — «не уверен», все поля 1:1 =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "predict 200" '[HTTP:200]'
|
||||
check "take:false/label:null/scores:{}" '"take":false' '"label":null' '"scores":{}'
|
||||
check "ready:false/terms:[]/type:null" '"ready":false' '"terms":[]' '"type":null'
|
||||
|
||||
echo
|
||||
echo "== 9c. POST /api/ml/reset — {ok:true} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" \
|
||||
-H "Content-Type: application/json" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "ml/reset 200 ok:true" '[HTTP:200]' '{"ok":true}'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
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 "passed:true" '"passed":true'
|
||||
|
||||
echo
|
||||
echo "== 10b. POST /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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "stage1 fail по стоп-фразе из настроек" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
|
||||
check "stage2 skipped, passed:false" '"stage2":{"pass":false,"reason":null,"skipped":true}' '"passed":false'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
show
|
||||
check "после logout settings 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$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)
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки сквозной curl-приёмки этапа 2 прошли"
|
||||
@@ -0,0 +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"}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Task 11 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
|
||||
|
||||
Статус: **complete (review pending)**. Build 0 warnings / 0 errors; unit-тесты 175/175 PASS;
|
||||
сквозная curl-приёмка :5080 — **PASS=60 FAIL=0** (один сценарий: `task-11-curl-acceptance.sh`,
|
||||
лог `task-11-curl-acceptance.log`). Код/конфиги (кроме доков и ledger) не менялись.
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 11 L393–409,
|
||||
Self-Review L411–429).
|
||||
|
||||
## Сквозной сценарий приёмки (один прогон, dev-БД очищается до/после)
|
||||
|
||||
| Шаг | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| 0 | psql: таблица `settings` дефолтного тенанта пуста; старт Deal.Api :5080 (Development) | PASS |
|
||||
| 1 | `GET /api/settings` без куки → 401 `{"detail":"Требуется авторизация"}` | 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 проверок) |
|
||||
| 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 проверок) |
|
||||
| 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 проверки) |
|
||||
| 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 проверок) |
|
||||
| 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 |
|
||||
| 12 | Очистка dev-БД (0 строк), сервер остановлен, порт 5080 свободен | PASS |
|
||||
|
||||
Порядок эндпоинтов совпадает с задачами 5/7→8→6→9→10, т.е. проверены все группы этапа 2 и их
|
||||
связность на одном состоянии БД: настройки из PATCH реально влияют на `/api/ai/check` (ветка по
|
||||
`aiProvider`) и `/api/admin/check-message` (`stopPhrases`/`minLen` из настроек), курсы кэшируются
|
||||
внутренним ключом, который не «протекает» в GET /settings.
|
||||
|
||||
## Сборка и тесты
|
||||
|
||||
```
|
||||
dotnet build Deal.sln (src/core) → 0 warnings / 0 errors
|
||||
dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175
|
||||
sh scripts/build.sh && sh scripts/test.sh → build 0/0; тесты 175 PASS
|
||||
```
|
||||
|
||||
## Изменения в доках и ledger
|
||||
|
||||
- `docs/technical/Техническая-документация-Дейл.md`: §11 — добавлен блок «Выполнено на этапе 2
|
||||
(2026-09-06)» (модуль Settings: настройки/шифрование/ai-check/rates/ML-заглушка/тестер, 175 PASS)
|
||||
и TODO про оживление Vue-фронта на этапе 3; §13 — заголовок «актуально для этапа 2», intro
|
||||
дополнен этапом 2 и кредами; новые подразделы «4a. Шифрование секретов настроек» (env
|
||||
`DEAL_ENCRYPTION_KEY`, файл `data/encryption.key`, env `DEAL_ENCRYPTION_KEY_FILE`, формат `enc:`,
|
||||
маски) и «4b. Эндпоинты этапа 2» (GET/PATCH `/settings` + таблица `settings` тенанта, `/ai/check`,
|
||||
`/rates*`, `/api/ml/*`, `/admin/check-message`); §13.6 — ожидается 175 PASS.
|
||||
- `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: заголовок «на конец этапа 2»; этап 2 внесён
|
||||
в «Выполнено» (задачи 1–11, 175 PASS, curl 60/60 + psql) с ограничением «фронт полностью оживёт на
|
||||
этапе 3»; из «Оставшихся этапов» блок этапа 2 убран (оставшиеся начинаются с этапа 3).
|
||||
- `.superpowers/sdd/deal-stage2-settings/progress.md`: строка «Task 11: complete (review pending).
|
||||
Отчёт: 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.log` — лог прогона (PASS=60 FAIL=0).
|
||||
- `.superpowers/sdd/deal-stage2-settings/task-11-patch.json` — тело PATCH-группы (переиспользованы
|
||||
`task-10-good.json`/`task-10-stop.json`/`task-9-predict.json` для текстов).
|
||||
|
||||
## Отклонения и замечания
|
||||
|
||||
1. **Формулировка «resume»-ветки в тестере**: этап-1 правила проверены в Task 10 (unit + curl);
|
||||
в сквозном сценарии Task 11 по плану гоняются только «валидный текст» и «текст со стоп-фразой» —
|
||||
resume/type-ветки здесь не повторяются (план Task 11 L400 требует именно стоп-фразу и валидный).
|
||||
2. **`/api/ml/candidates|apply` не повторялись в сквозном сценарии** — они приняты в Task 9
|
||||
(curl 31/31); сценарий Task 11 включает status/predict/reset (план L396–399).
|
||||
3. **Psql-колонки — PascalCase** (`Key`/`ValueJson`/`UpdatedAt`, конвенция EF): psql-проверка
|
||||
использует кавычки `"Key"`/`"ValueJson"` (в первых прогонах сценария snake_case-запросы падали —
|
||||
исправлено в скрипте; финальный прогон 60/60).
|
||||
4. **ai/check в сценарии детерминирован без внешней сети**: активный провайдер `ollama` (локальный) —
|
||||
ветка «по настройкам» без реального HTTP; облачные ветки (401/403/сеть) приняты в Task 6.
|
||||
5. Известные ограничения этапа зафиксированы в отчёте Task 11 плана и в доке §11: Telegram-вкладка,
|
||||
«Проверить правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи»
|
||||
(ai/suggest-keywords), канбан-фронт и полный `boot()` Vue-фронта — этапы 3–6 (Ruling 8/11);
|
||||
реальные ml/ai/telegram-сервисы — этап 6.
|
||||
6. Docker из direct-команд терминала недоступен (sandbox), psql внутри sh-скриптов работает —
|
||||
приёмка выполнялась скриптом, как и в Tasks 5–10.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → 0 warnings / 0 errors
|
||||
dotnet test Deal.sln --no-build → 175 PASS
|
||||
sh task-11-curl-acceptance.sh → PASS=60 FAIL=0 (лог task-11-curl-acceptance.log)
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Task 2 — «Модуль Settings: каталог ключей, дефолты, DTO, порт хранилища» — отчёт
|
||||
|
||||
Статус: **complete**. Build 0 warnings / 0 errors; тесты 51/51 PASS (было 38, добавлено 13 — `SettingsCatalogTests`).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 2 L148–184, Ruling 1/3/4/9/10).
|
||||
|
||||
## Файлы
|
||||
|
||||
Все созданы в `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/SettingsKeys.cs` | static class | Константы имён ключей (39 публичных + 3 внутренних) + каталог `PublicKeys: ключ → SettingKind` + `FindPublicKind`. |
|
||||
| `Application/SettingsDefaults.cs` | static class | Дефолтные значения (см. ниже). |
|
||||
| `Application/DefaultPrompts.cs` | static class | `DefaultAiPrompt`/`DefaultCardPrompt`/`DefaultAiFilterPrompt` — тексты из `data.js`. |
|
||||
| `Application/AiProviderDefinition.cs` | record | Id/Name/Base/Local/Models/ApiStyle? (`null`=OpenAI-совм., `"anthropic"`). |
|
||||
| `Application/AiProviders.cs` | static class | Список 7 провайдеров. |
|
||||
| `Application/MockRates.cs` | static class | Мок-курсы + `RatesFetchInterval` (6 ч). |
|
||||
| `Application/ISettingsStore.cs` | interface | Порт KV-хранилища (см. ниже). |
|
||||
| `Application/Models/SettingValue.cs` | record | `(Key, ValueJson, UpdatedAt)` — элемент порта. |
|
||||
| `Application/Models/AiConfigSetting.cs` | record | Внутренняя (БД) форма конфига провайдера: `(ApiKey, BaseUrl, Model)`. |
|
||||
| `Application/Models/TgKeysSetting.cs` | record | Внутренняя (БД) форма: `(ApiId, ApiHash)`. |
|
||||
| `Application/Models/MyPromptDto.cs` | record | Элемент «Моих промптов» (public). |
|
||||
| `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/ProviderPublicDto.cs` | record | `(Id, Name, Base, Local, Models)` — public-форма провайдера (без `api_style`). |
|
||||
| `Application/Models/PublicSettingsDto.cs` | record | Все поля public-снимка §4.6, PascalCase-свойства (наружу camelCase даёт ASP.NET). |
|
||||
| `tests/Deal.Tests.Unit/SettingsCatalogTests.cs` | xUnit | 13 тестов (см. ниже). |
|
||||
|
||||
## Каталог ключей (`SettingsKeys.PublicKeys`, 39 ключей)
|
||||
|
||||
- **Int (9):** `archiveAfterDays`, `archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discEvalSample`, `discEvalThreshold`.
|
||||
- **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`.
|
||||
- **List (5):** `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`.
|
||||
- **Dict (1):** `colState`.
|
||||
- **special:** `myPrompts` (MyPrompts), `aiConfigs` (AiConfigs), `tgKeys` (TgKeys).
|
||||
- **Внутренние (константы, вне `PublicKeys` — Ruling 1):** `ratesCache`, `mlDecisions`, `aiDecisions`.
|
||||
|
||||
Список сверен с планом Task 2 (L152–161) и api-map §4.6/PATCH L340 (тест `PublicKeys_CoverAllApiMapKeysWithCorrectCategories` — эталон из 39 wire-имён).
|
||||
|
||||
## Источники значений
|
||||
|
||||
- **Дефолты** — `backend/app/constants.py`: `DEFAULT_SETTINGS` L189–245, стоп-фразы L55, маркеры найма L144–149, грейдов L151–154, резюме L163–167; `aiConfigs` — на каждого провайдера (пустой ключ, base, первая модель, constants.py L231–234); `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 L165–167). В `constants.py` те же тексты L63–141, но с расхождениями в разбивке на строки и формулировке (например, пункт «title»), поэтому за основу взят data.js. Тексты скопированы построчно; сверка на данном шаге — маркерные тесты (`{domain}`, `{keywords}`, «О заявке», «страж входящих»); полное сравнение строк — в Task 7 (`PromptDefaultsTests`).
|
||||
- **Провайдеры** — `constants.py` `AI_PROVIDERS` L170–186 (7 шт., значения совпадают с `data.js` AI_PROVIDERS L17–80). `api_style="anthropic"` только у anthropic — внутреннее поле, в public-форму не выходит (Ruling 3).
|
||||
- **Мок-курсы** — `constants.py` `MOCK_RATES` L41–50; интервал 6 ч — `rates.py` L20 (Ruling 6).
|
||||
|
||||
## Порт `ISettingsStore`
|
||||
|
||||
```csharp
|
||||
Task<SettingValue?> GetAsync(string key, CancellationToken ct);
|
||||
Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct);
|
||||
Task SetAsync(string key, string valueJson, CancellationToken ct);
|
||||
Task RemoveAsync(string key, CancellationToken ct);
|
||||
```
|
||||
|
||||
`SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt)` — 1:1 со строкой таблицы `settings` (сущность `TenantSettingEntity`: `Key`/`ValueJson`/`UpdatedAt` уже есть, новых EF-таблиц нет).
|
||||
|
||||
## Тесты (`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 посеяны на всех провайдеров с пустым ключом и первой моделью; промпт-маркеры.
|
||||
|
||||
## Расхождения с планом (и почему)
|
||||
|
||||
1. **Порт оперирует JSON-строками (`SettingValue.ValueJson`), а не `object?`-значениями** (план L173–175: `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 остаётся их потребителем.
|
||||
3. **Добавлены внутренние модели `AiConfigSetting`/`TgKeysSetting`** (в плане нет): нужны `SettingsDefaults` для точных дефолтов `aiConfigs`/`tgKeys` (план L164: «aiConfigs для каждого провайдера с первым model, tgKeys={apiId:"",apiHash:""}») без `object?`-словарей; переиспользуются Task 3 при (де)сериализации сохранённых значений.
|
||||
4. **`RatesFetchInterval` размещён в `MockRates`** (план L172 группирует его с MockRates в один файл): один файл — один тип, поэтому интервал стал членом класса `MockRates` (обе величины — константы раздела «курсы»).
|
||||
5. **Промпты — `static readonly`, не `const`** (план L165: «константы»): многострочные тексты в `const` невозможны; значения неизменяемы и инициализируются один раз (нормализация `\r\n`→`\n` и снятие завершающего перевода строки raw-литерала для идентичности строке шаблона data.js).
|
||||
6. **Registrar не создавался** (п. 6 ТЗ «может быть заготовлена, если план требует»): план создаёт `SettingsModuleRegistrar` в Task 4 (L228) — здесь не требуется.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (11 проектов)
|
||||
dotnet test tests/Deal.Tests.Unit → всего: 51; сбой: 0; успешно: 51; пропущено: 0 (было 38 → +13)
|
||||
grep 'Infrastructure|EntityFramework|Npgsql' в src/core/Deal.Modules.Settings → 0 совпадений
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# Task 3 — «SettingsService: public-снимок и частичное обновление (PATCH 1:1)» — отчёт
|
||||
|
||||
Статус: **complete**. Build 0 warnings / 0 errors; тесты 87/87 PASS (было 51, добавлено 36 — `SettingsServiceTests`).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 3 L184–220, Rulings 1/3/9/10; референс `settings_routes.py` L75–192, `store.js` applySettings, api-map §4.6/§4.7).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `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/FakeSecretCipher.cs` | class | Детерминированный шифр «enc: + Base64»: честен для сценария «сбойный токен → ""». |
|
||||
| `src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs` | xUnit | 36 тестов (см. ниже). |
|
||||
|
||||
DTO не создавались — используются записи Task 2 (`PublicSettingsDto`, `AiConfigPublicDto`, `TgKeysPublicDto`, `AiConfigSetting`, `TgKeysSetting`, `MyPromptDto`, `SettingValue`).
|
||||
|
||||
## Поведение и сигнатуры (по плану Task 3 L190–193)
|
||||
|
||||
```csharp
|
||||
public sealed class SettingsService(ISettingsStore store, ISecretCipher secretCipher)
|
||||
public Task<PublicSettingsDto> GetPublicAsync(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-строка в БД не роняет снимок (трактуется как отсутствующая).
|
||||
- **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` без клампа.
|
||||
- Интервалы задержек: пара — клампы 5..600 затем swap при min>max; один конец — кламп относительно эффективного (дефолт+stored) другого конца (L80–109).
|
||||
- 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.
|
||||
- `aiConfigs`: только провайдеры каталога; `baseUrl`/`model` — строки; `apiKey` ≥8 без префикса `enc:` → `Encrypt` (в БД `enc:`); `keySet`/`keyMasked` наружу не пишутся, вычисляются при чтении.
|
||||
- `tgKeys`: `apiId` — только ASCII-цифры, длина 6..9 (stored открыто); `apiHash` ≥16 без `enc:` → `Encrypt`.
|
||||
- Хранение только переопределений: применённое значение aiConfigs/tgKeys, равное дефолту, в БД не пишется (`RemoveAsync`); секреты никогда не возвращаются в открытом виде (маска `1234…5678`, символ «…»).
|
||||
|
||||
## Тесты (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.
|
||||
|
||||
## Расхождения (план vs бриф/прототип)
|
||||
|
||||
1. **Имена методов**: бриф задачи называл `GetPublicSnapshotAsync`/`ApplyPatchAsync(SettingsPatchDto)`; план (файл, L190–193) — `GetPublicAsync(ct)` и `ApplyPatchAsync(Dictionary<string, JsonElement> body, ct)`. Взят план: Task 5 («тело — произвольный JSON-объект») и мягкая семантика («невалидное поле просто не применяется») требуют словаря JsonElement, а не типизированного DTO (иначе ошибки десериализации тела). `SettingsPatchDto` не создавался.
|
||||
2. **Приёмка Task 5 (L256–257)**: `{discJoinDelayMin:700, discJoinDelayMax:5}` ожидает ответ `{5, 700}`; фактически по коду прототипа (L88–93: клампы ПЕРЕД swap) и семантике Task 3 (L199–201) ответ `{5, 600}` (700 → 600, затем swap). Следовал коду прототипа; строку приёмки Task 5 считаю опечаткой (swap без клампов).
|
||||
3. **Bool**: прототип хранит `bool(value)` (строка `"false"` → true); по плану Task 3 строки не «питон-булеватся» — только JSON true/false. План приоритетнее.
|
||||
4. **List**: не-скалярные элементы (объект/массив/null) пропускаются, а не `str()`-ятся как в Python (`"None"`, `"{...}"`); срез 200 после конвертации — 1:1.
|
||||
5. **Int**: принимается JSON-число (int64) и числовая строка; float/булево/>int64 — «нечисловое» → пропуск (Python `int()` конвертирует шире). Реалистичные запросы фронта — целые.
|
||||
6. **«Один конец интервала»** клампится относительно эффективного другого конца (дефолт+stored): в прототипе БД посеяна дефолтами, в нашей модели дефолты в коде — для пустого хранилища результат эквивалентен (мин. 700 → 70; макс. 5 → 50).
|
||||
7. **aiConfigs в БД** — полный эффективный словарь всех провайдеров (как в прототипе, где строка всегда полна); GET мержит дефолт+stored по провайдеру — устойчиво к дрейфу каталога. «Только существующие провайдеры» = каталог `AiProviders` (дефолтный aiConfigs покрывает всех).
|
||||
8. **Побочные эффекты** (fire-and-forget `RatesService.RefreshAsync` при `rateSource`, пересчёт при `targetCurrency`/`conversionOn`, L186–192) в сервис не входят: Tasks 5/8; в этапе 2 leads нет.
|
||||
9. **`Decrypt` без `enc:` → ""** (решение T1, legacy-данных нет) — legacy-открытый секрет показал бы `keySet=false`; запись всегда шифрует, строки-исключения нет.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Сборка успешно, 0 предупреждений / 0 ошибок (11 проектов)
|
||||
dotnet test tests/Deal.Tests.Unit → всего: 87; не пройдено: 0; успешно: 87 (было 51 → +36)
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Deal.SettingsDevCheck</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\core\Deal.Infrastructure\Deal.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\core\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,115 @@
|
||||
using Deal.Infrastructure;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Deal.Infrastructure.Persistence.Repositories;
|
||||
using Deal.Infrastructure.Security;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
// Dev-проверка Task 4 (план L235–236): KV-адаптер SettingsStore (EF) и DI против реального
|
||||
// Postgres deal-postgres (:5433, контейнер deploy/compose.dev.yml). Проект лежит вне решения —
|
||||
// в сборку/тесты src/core не входит. Сценарий = acceptance задачи: на пустой схеме тенанта
|
||||
// GET через сервис возвращает дефолты; SetAsync создаёт строку с value_json (её затем показывает
|
||||
// psql); upsert/remove/updated_at — семантика адаптера. Проверочная схема devcheck_t4
|
||||
// создаётся tenant-миграциями (как TenantProvisioningService) и удаляется после psql-проверки.
|
||||
|
||||
const string baseConnection = "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
const string schemaName = "devcheck_t4";
|
||||
const string migrationsHistoryTable = "__TenantMigrationsHistory";
|
||||
|
||||
int failures = 0;
|
||||
|
||||
Console.WriteLine($"== Сброс и провижининг схемы {schemaName} (tenant-миграции) ==");
|
||||
await ExecSqlAsync($"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE; CREATE SCHEMA \"{schemaName}\";");
|
||||
|
||||
var migrationOptions = new DbContextOptionsBuilder<TenantDbContext>()
|
||||
.UseNpgsql(
|
||||
$"{baseConnection};Search Path={schemaName}",
|
||||
npgsql => npgsql.MigrationsHistoryTable(migrationsHistoryTable, schemaName))
|
||||
.Options;
|
||||
await using (var migrator = new TenantDbContext(migrationOptions))
|
||||
{
|
||||
await migrator.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== DI: AddDealPersistence() + AddSettingsModule() + scoped TenantDbContext ==");
|
||||
var services = new ServiceCollection();
|
||||
services.AddDbContext<TenantDbContext>(options => options.UseNpgsql($"{baseConnection};Search Path={schemaName}"));
|
||||
services.AddDealPersistence();
|
||||
// Ключ шифрования не важен: секреты (aiConfigs/tgKeys) в проверке не пишутся — только дефолтный снимок.
|
||||
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(new byte[32]));
|
||||
services.AddSettingsModule();
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
await using AsyncServiceScope scope = provider.CreateAsyncScope();
|
||||
|
||||
SettingsService settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
ISettingsStore store = scope.ServiceProvider.GetRequiredService<ISettingsStore>();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== 1. Пустая схема: GET через сервис -> дефолты ==");
|
||||
Check((await store.GetAllAsync(default)).Count == 0, "хранилище пусто (0 строк)");
|
||||
Check(await store.GetAsync(SettingsKeys.ArchiveAfterDays, default) is null, "GetAsync отсутствующего ключа -> null");
|
||||
PublicSettingsDto snapshot = await settings.GetPublicAsync(default);
|
||||
Check(snapshot.AiEnabled, "aiEnabled=true");
|
||||
Check(snapshot.MlEnabled, "mlEnabled=true");
|
||||
Check(snapshot.MinLen == 24, "minLen=24");
|
||||
Check(snapshot.ArchiveAfterDays == 14, "archiveAfterDays=14");
|
||||
Check(snapshot.StopPhrases.Count == 4, "stopPhrases: 4 дефолтные");
|
||||
Check(snapshot.WantedType == "both", "wantedType=both");
|
||||
Check(snapshot.RateSource == "cbr", "rateSource=cbr");
|
||||
Check(snapshot.AiProvider == "deepseek", "aiProvider=deepseek");
|
||||
Check(snapshot.TgKeys.ApiId == string.Empty && !snapshot.TgKeys.ApiHashSet, "tgKeys={apiId:\"\", apiHashSet:false}");
|
||||
Check(snapshot.ColState.Count == 0, "colState={}");
|
||||
Check(snapshot.Providers.Count == 7, "providers: 7 шт.");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== 2. SetAsync (upsert по PK) + Get/GetAll + updated_at UTC ==");
|
||||
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
|
||||
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(default);
|
||||
Check(rows.Count == 1, "SetAsync создал 1 строку");
|
||||
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 && DateTimeOffset.UtcNow - row.UpdatedAt < TimeSpan.FromMinutes(1), "updated_at ~ UTC-now");
|
||||
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
|
||||
Check((await store.GetAllAsync(default)).Count == 1, "повторный SetAsync не дублирует (upsert по PK)");
|
||||
await store.SetAsync(SettingsKeys.ColState, "{\"kanban\":true}", default);
|
||||
Check((await store.GetAllAsync(default)).Count == 2, "второй ключ — вторая строка");
|
||||
Check(await store.GetAsync(SettingsKeys.ColState, default) is { ValueJson: "{\"kanban\":true}" }, "value_json хранится текстом как есть");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== 3. RemoveAsync ==");
|
||||
await store.RemoveAsync("noSuchKey", default);
|
||||
Check((await store.GetAllAsync(default)).Count == 2, "RemoveAsync отсутствующего ключа — no-op");
|
||||
await store.RemoveAsync(SettingsKeys.ColState, default);
|
||||
Check((await store.GetAllAsync(default)).Count == 1, "RemoveAsync удалил существующую строку");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== 4. GET через сервис после SetAsync (переопределение дефолта) ==");
|
||||
PublicSettingsDto after = await settings.GetPublicAsync(default);
|
||||
Check(after.ArchiveAfterDays == 30, "archiveAfterDays=30 — сохранённое переопределило дефолт 14");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(failures == 0 ? "ИТОГ: все проверки прошли." : $"ИТОГ: не прошли проверок: {failures}.");
|
||||
Console.WriteLine($"Схема {schemaName} оставлена с 1 строкой settings для psql-проверки value_json.");
|
||||
Environment.ExitCode = failures == 0 ? 0 : 1;
|
||||
|
||||
void Check(bool condition, string description)
|
||||
{
|
||||
Console.WriteLine($" {(condition ? "[PASS]" : "[FAIL]")} {description}");
|
||||
if (!condition)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
async Task ExecSqlAsync(string sql)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(baseConnection);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Task 4 — «KV-адаптер SettingsStore (EF) и DI» — отчёт
|
||||
|
||||
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (было 87, добавлен 1);
|
||||
psql-приёмка на реальном `deal-postgres` (:5433) — все проверки PASS (см. ниже).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 4 L220–236, Ruling 1;
|
||||
эталон — `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/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/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/tests/Deal.Tests.Unit/TenantSettingEntityTests.cs` | xUnit | Лёгкий тест сущности `TenantSettingEntity` (по образцу `TenantEntityTests`). |
|
||||
| `.superpowers/sdd/deal-stage2-settings/task-4-devcheck/` (csproj+Program.cs) | dev-check | Консольная приёмка адаптера против реального Postgres (вне решения, в сборку не входит). |
|
||||
|
||||
`Deal.Infrastructure.csproj` уже ссылался на `Deal.Modules.Settings` — изменений не потребовалось.
|
||||
|
||||
## Поведение адаптера
|
||||
|
||||
- `GetAsync` — `AsNoTracking`, `SingleOrDefaultAsync` по PK `Key`; отсутствующий ключ → `null` (модуль трактует как дефолт).
|
||||
- `GetAllAsync` — все строки, `AsNoTracking`, упорядочены по `Key`.
|
||||
- `SetAsync` — upsert по PK: существующая строка обновляется, отсутствующая добавляется; `UpdatedAt = DateTimeOffset.UtcNow`; сохранение — `SaveChangesAsync` (в стиле `AuthStore.CreateSessionAsync`).
|
||||
- `RemoveAsync` — `ExecuteDeleteAsync` по ключу: существующая строка удаляется, отсутствующий ключ — no-op (в стиле `AuthStore.DeleteSessionAsync`).
|
||||
- Маппинг DTO ↔ сущности — вручную (порт модуля не видит EF-сущности), как в эталонных адаптерах.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **`SettingsModuleRegistrar` регистрирует только `SettingsService`.** Список файлов Task 4 упоминает `RatesService` и
|
||||
`IncomingRules` — их типов в модуле ещё нет (задачи 8/10), регистрация несуществующих типов сломала бы build 0/0.
|
||||
Регистратор расширяется по мере появления сервисов модуля (задачи 8/10 добавляют свои строки).
|
||||
2. **Вызов `AddSettingsModule()` в `Program.cs` и runtime-регистрация scoped `TenantDbContext` — Task 5** (план: «вызывается
|
||||
в A/Program.cs (Task 5)», L229). Поэтому HTTP/curl-часть приёмки (`GET /api/settings`) невозможна до Task 5.
|
||||
3. **psql-приёмка выполнена dev-check-харнессом** (п. «Проверки»): эндпоинтов ещё нет, поэтому acceptance исполнен на уровне
|
||||
«сервис + адаптер» против реального `deal-postgres` на временной схеме `devcheck_t4` (провижининг — tenant-миграцией,
|
||||
как `TenantProvisioningService`), после проверки схема удалена. Схема дефолтного тенанта не тронута (0 строк settings, как было).
|
||||
4. **Unit-тест адаптера без БД невозможен** (EF-провайдер нужен); InMemory по конвенции проекта не используется.
|
||||
В unit добавлен лёгкий тест сущности-носителя (образец `TenantEntityTests`), полный сценарий — dev-проверка выше.
|
||||
5. **Ключ шифрования в dev-check — нулевой** (32 нулевых байта): секреты aiConfigs/tgKeys в проверке не пишутся,
|
||||
используется только дефолтный снимок (AES-операций нет) — достаточно для проверки KV-адаптера и DI.
|
||||
|
||||
## Проверки
|
||||
|
||||
### build + unit
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0. (11 проектов)
|
||||
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88 (было 87 → +1)
|
||||
```
|
||||
|
||||
### psql / dev-check (acceptance Task 4, L235–236)
|
||||
|
||||
Харнесс (схема `devcheck_t4`, tenant-миграция `InitialTenant` применена; DI: `AddDealPersistence` +
|
||||
`AddSettingsModule` + scoped `TenantDbContext`) — все 24 проверки `[PASS]`:
|
||||
|
||||
1. **Пустая схема: GET через сервис → дефолты** — `aiEnabled:true, mlEnabled:true, minLen:24,
|
||||
archiveAfterDays:14, stopPhrases:4, wantedType:"both", rateSource:"cbr", aiProvider:"deepseek",
|
||||
tgKeys:{apiId:"",apiHashSet:false}, colState:{}, providers:7` (совпадает с Task 5-приёмкой L253–255);
|
||||
`GetAsync` отсутствующего ключа → null.
|
||||
2. **SetAsync создаёт строку** — после `SetAsync("archiveAfterDays","30")`:
|
||||
`GetAllAsync` → 1 строка, `GetAsync` → `value_json="30"`, `updated_at` ≈ UTC-now; повторный SetAsync
|
||||
не дублирует (upsert по PK); второй ключ — вторая строка; JSON хранится текстом как есть.
|
||||
3. **RemoveAsync** — отсутствующий ключ no-op; существующая строка удаляется.
|
||||
4. **GET через сервис после записи** — `archiveAfterDays:30` (переопределение дефолта 14).
|
||||
|
||||
psql после прогона (строка на месте, до очистки схемы):
|
||||
|
||||
```
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c 'SELECT "Key", "ValueJson", "UpdatedAt" FROM devcheck_t4.settings;'
|
||||
Key | ValueJson | UpdatedAt
|
||||
------------------+-----------+-------------------------------
|
||||
archiveAfterDays | 30 | 2026-09-06 01:13:55.443698+00
|
||||
```
|
||||
|
||||
Очистка: `DROP SCHEMA devcheck_t4 CASCADE` выполнен; `\dn` — только public + tenant_…001; в схеме дефолтного
|
||||
тенанта settings пуста (как до проверки).
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 5 curl-приёмка GET/PATCH /api/settings на :5080 (план Task 5 L252–263).
|
||||
# Сценарий: 401 без куки → login admin/admin → GET дефолты → PATCH клампы+swap →
|
||||
# myPrompts → aiConfigs (deepseek apiKey) → tgKeys → поля (archiveAfterDays/minLen/stopPhrases/colState)
|
||||
# → GET: изменения видны, секреты замаскированы → PATCH невалидных клампов повторно →
|
||||
# неизвестный ключ → logout. В конце psql: строки settings + enc:. Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
CORE_DIR="C:/telbase/src/core"
|
||||
API_DIR="$CORE_DIR/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task5-jar.txt"
|
||||
OUT="/tmp/task5-out.txt"
|
||||
LOG="/tmp/task5-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
check_count() {
|
||||
# $1 — описание; $2 — подстрока; $3 — ожидаемое число вхождений
|
||||
desc=$1
|
||||
pat=$2
|
||||
want=$3
|
||||
got=$(grep -oF -- "$pat" "$OUT" | wc -l)
|
||||
if [ "$got" = "$want" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc ($got)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — найдено $got, ожидалось $want"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG" /tmp/task5-body8.json
|
||||
|
||||
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторные прогоны
|
||||
# и прерванные запуски могли оставить переопределения). Чистим до старта сервера.
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
# Ждём health (до 30 с; bootstrap провижинит схему дефолтного тенанта до первого ответа).
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/settings без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 1b. PATCH /api/settings без куки — ожидаем 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"archiveAfterDays":7}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 PATCH без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 2b. PATCH /api/settings невалидный JSON — ожидаем 400 {detail} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"archiveAfterDays":' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "400 на не-JSON тело" '[HTTP:400]' '"detail":"Тело запроса должно быть JSON-объектом"'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/settings — дефолты (пустая таблица settings) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "int-дефолты" '"archiveAfterDays":14' '"minLen":24'
|
||||
check "bool-дефолты" '"autoArchive":true' '"aiEnabled":true' '"mlEnabled":true'
|
||||
check "string-дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
|
||||
check "stopPhrases — 4 дефолтные" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
|
||||
check "tgKeys пустые" '"apiId":""' '"apiHashSet":false'
|
||||
check "colState пустой" '"colState":{}'
|
||||
check "aiConfigs deepseek пустой ключ" '"deepseek":' '"keySet":false' '"keyMasked":""'
|
||||
check_count "providers — 7 провайдеров" '"id":"' 7
|
||||
|
||||
echo
|
||||
echo "== 4. PATCH клампы+swap {archiveAfterDays:99, minLen:3, delayMin:700, delayMax:5} =="
|
||||
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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "клампы применены" '"archiveAfterDays":30' '"minLen":10'
|
||||
check "интервал задержек {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"myPrompts":[{"name":"x","prompt":"y"},{"name":"","prompt":""}]}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check_count "ровно 1 промпт" '"name":"x"' 1
|
||||
check "id генерируется с префиксом pp_" '"id":"pp_'
|
||||
|
||||
echo
|
||||
echo "== 6. PATCH aiConfigs.deepseek.apiKey sk-1234567890ab =="
|
||||
echo " ожидаем keySet:true, keyMasked: sk-1…90ab"
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "deepseek keySet/mask" '"keySet":true' '"keyMasked":"sk-1…90ab"'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "apiId как есть (len<=8) и apiHashSet true" '"apiId":"123456"' '"apiHashSet":true'
|
||||
|
||||
echo
|
||||
# Тело с кириллицей передаём файлом (UTF-8): в Windows curl аргумент командной строки
|
||||
# с кириллицей перекодируется в cp1251, и сервер получает невалидный UTF-8.
|
||||
cat > /tmp/task5-body8.json <<'BODY8'
|
||||
{"archiveAfterDays":7,"minLen":30,"stopPhrases":["стоп раз","стоп два"],"colState":{"review":1,"done":2}}
|
||||
BODY8
|
||||
|
||||
echo "== 8. PATCH поля: archiveAfterDays=7, minLen=30, stopPhrases, colState =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "новые значения в ответе" '"archiveAfterDays":7' '"minLen":30'
|
||||
check "stopPhrases заменены" '"stopPhrases":["стоп раз","стоп два"]'
|
||||
check "colState passthrough" '"colState":{"review":1,"done":2}'
|
||||
|
||||
echo
|
||||
echo "== 9. GET /api/settings — финальная проверка: изменения видны, секреты замаскированы =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "переопределения применены" '"archiveAfterDays":7' '"minLen":30'
|
||||
check "stopPhrases новые" '"stopPhrases":["стоп раз","стоп два"]'
|
||||
check "colState новый" '"colState":{"review":1,"done":2}'
|
||||
check "myPrompts сохранён" '"name":"x"' '"id":"pp_'
|
||||
check "deepseek keySet+маска" '"keySet":true' '"keyMasked":"sk-1…90ab"'
|
||||
if grep -qF "sk-1234567890ab" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] открытый ключ sk-1234567890ab утёк в ответ GET"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] открытого ключа в ответе GET нет (только маска sk-1…90ab)"
|
||||
fi
|
||||
check "tgKeys apiHashSet" '"apiHashSet":true'
|
||||
|
||||
echo
|
||||
echo "== 10. PATCH невалидных клампов повторно {archiveAfterDays:99, delayMin:700, delayMax:5} =="
|
||||
echo " ожидаем archiveAfterDays:30, delay {5,600}"
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"archiveAfterDays":99,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "кламп 30" '"archiveAfterDays":30'
|
||||
check "delay {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
|
||||
|
||||
echo
|
||||
echo "== 11. PATCH неизвестного ключа {\"foo\":1} — мягкая семантика, без ошибки, без foo =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"foo":1}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200 без detail" '[HTTP:200]'
|
||||
if grep -qF '"foo"' "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] foo попал в снимок"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] foo отсутствует в снимке"
|
||||
fi
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 13. psql: строки settings в схеме дефолтного тенанта; aiConfigs/tgKeys зашифрованы enc: =="
|
||||
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
|
||||
echo "--- проверка enc: в секретах ---"
|
||||
$PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';"
|
||||
echo
|
||||
echo
|
||||
echo "== 14. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию; psql-свидетельство выше) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1,109 @@
|
||||
# Task 5 — «Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка» — отчёт
|
||||
|
||||
Статус: **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`).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 5 L238–263, Rulings 3/8/11;
|
||||
эталон — `AuthEndpoints.cs`). Приёмка delay-клампов — **{5,600}**, не {5,700} из плана (опечатка плана;
|
||||
клампы 5..600 ПЕРЕД swap — см. ledger/отчёт T3).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `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`). |
|
||||
| `.superpowers/sdd/deal-stage2-settings/task-5-curl-acceptance.sh` | sh | curl-приёмка (42 проверки, см. ниже). |
|
||||
|
||||
## Регистрация TenantDbContext (Task 5, п.1)
|
||||
|
||||
В `Program.cs` рядом с `DealDbContext`/`ITenantContext`/`ConnectionStringProvider` добавлено:
|
||||
|
||||
- `AddDbContext<TenantDbContext>((sp, options) => …)` с `contextLifetime/optionsLifetime: Scoped`.
|
||||
**optionsLifetime: Scoped** обязателен: при singleton-опциях строка подключения первого запроса
|
||||
(с Search Path его тенанта) закешировалась бы на весь процесс. Опции строятся на каждый scope по
|
||||
текущему `ITenantContext` (заполняет `SessionMiddleware`) → `ConnectionStringProvider.ForTenant(TenantId)`.
|
||||
`MigrationsHistoryTable("__TenantMigrationsHistory")` **без схемы** (схема — search_path; runtime-контекст
|
||||
миграции не выполняет — их применяет `TenantProvisioningService` на старте).
|
||||
- Безопасность: `!tenantContext.HasTenant` → `InvalidOperationException` с понятным сообщением **при резолве**
|
||||
(500 на запрос = ошибка конфигурации; на нормальных запросах без сессии эндпоинт отвечает 401 раньше).
|
||||
|
||||
## Эндпоинты (Task 5, п.2, контракт §3.4/§4.6)
|
||||
|
||||
`GET /api/settings` → 200, публичный снимок (дефолты+переопределения, маски Ruling 3, providers).
|
||||
`PATCH /api/settings` — тело произвольный JSON-объект → `ApplyPatchAsync` → 200, полный снимок после
|
||||
применения (фронт затирает локальный state ответом). 401 `{"detail":"Требуется авторизация"}` без сессии;
|
||||
не-JSON-объект тела → 400 `{"detail":"Тело запроса должно быть JSON-объектом"}` (в прототипе FastAPI — 422).
|
||||
Мягкая семантика невалидных полей — из сервиса (Task 3), эндпоинт исключений не добавляет.
|
||||
|
||||
**Отклонение (важное):** `SettingsService` резолвится из `context.RequestServices` **внутри обработчика после
|
||||
проверки сессии**, а не параметром эндпоинта. DI-биндинг параметров минимальных API выполняется до тела
|
||||
обработчика, а зависимость сервиса — scoped `TenantDbContext`, опции которого требуют tenant-контекст
|
||||
(без сессии — не разрешим). При резолве параметром запрос без куки получал бы 500 вместо 401 —
|
||||
проверено эмпирически на первом прогоне (см. Concerns). Поведение 401 для GET и PATCH подтверждено.
|
||||
|
||||
## Проверки
|
||||
|
||||
### build + unit
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0.
|
||||
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88
|
||||
```
|
||||
|
||||
### curl-приёмка (PASS=42 FAIL=0; полный вывод — task-5-curl-acceptance.log)
|
||||
|
||||
1. Очистка `settings` дефолтного тенанта (повторяемость) → GET/PATCH без куки → **401** `Требуется авторизация`;
|
||||
PATCH невалидного JSON с кукой → **400** `Тело запроса должно быть JSON-объектом`.
|
||||
2. login admin/admin → **GET дефолты**: `autoArchive/aiEnabled/mlEnabled:true`, `archiveAfterDays:14, minLen:24`,
|
||||
`stopPhrases` 4 дефолтные, `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`,
|
||||
`tgKeys {apiId:"", apiHashSet:false}`, `colState:{}`, aiConfigs keySet:false/keyMasked:"", **providers: 7**.
|
||||
3. `PATCH {archiveAfterDays:99, minLen:3, discJoinDelayMin:700, discJoinDelayMax:5}` →
|
||||
**`archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:600`** (swap, кламп {5,600}).
|
||||
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"**.
|
||||
6. `PATCH tgKeys {apiId:"123456", apiHash:"abcdefghijklmnop"}` → **apiId:"123456", apiHashSet:true**.
|
||||
7. `PATCH {archiveAfterDays:7, minLen:30, stopPhrases:[«стоп раз»,«стоп два»], colState:{review:1,done:2}}`
|
||||
(тело файлом UTF-8, см. Concerns) → значения применены, colState passthrough как есть.
|
||||
8. **GET после PATCH**: все изменения видны; секреты замаскированы (`sk-1…90ab`), открытого ключа нет;
|
||||
keySet/apiHashSet true; myPrompts/colState на месте.
|
||||
9. Повторный PATCH невалидных клампов `{99, 700/5}` → **30 и {5,600}**.
|
||||
10. `PATCH {foo:1}` → 200 без ошибки, `foo` в снимке отсутствует. → logout 200.
|
||||
|
||||
### psql (до очистки; схема `tenant_000…001`)
|
||||
|
||||
```
|
||||
9 строк: aiConfigs, archiveAfterDays=30, colState, discJoinDelayMin=5, discJoinDelayMax=600,
|
||||
minLen=30, myPrompts, stopPhrases, tgKeys. enc: — в 2 строках:
|
||||
aiConfigs → "deepseek":{"apiKey":"enc:5quPTxzr…","baseUrl":… (полный словарь всех провайдеров)
|
||||
tgKeys → {"apiId":"123456","apiHash":"enc:PKqyPl…"}
|
||||
```
|
||||
|
||||
После прогона dev-БД возвращена к исходному состоянию: `DELETE FROM …settings` → 0 строк; процесс остановлен,
|
||||
порт :5080 свободен, процессов `Deal.Api` нет.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Резолв `SettingsService` через `RequestServices` внутри обработчиков** — см. выше; поведение (401 без
|
||||
сессии, а не 500) зафиксировано в логе прогона-1 и в приёмке.
|
||||
2. **`optionsLifetime: Scoped`** для `TenantDbContext` — осознанный выбор multi-tenancy: per-scope строка
|
||||
подключения (иначе singleton-опции «залипли» бы на первом тенанте). Модель кешируется EF на внутренний
|
||||
провайдер (ключ — опции), поэтому повторные запросы того же тенанта дешёвые; кол-во тенантов на инсталляцию
|
||||
небольшое (dev — 1).
|
||||
3. **Приёмка delay-клампов {5,600}** — план в L257 ожидает `discJoinDelayMax:700` (опечатка); фактическое
|
||||
поведение (клампы 5..600 перед swap, референс `settings_routes.py` L88–93) — `{5,600}`, подтверждено в двух
|
||||
шагах приёмки и psql (`discJoinDelayMin=5, discJoinDelayMax=600`).
|
||||
4. **Тело с кириллицей в curl** передаётся файлом UTF-8 (`--data-binary @file`): в Windows curl конвертирует
|
||||
аргумент командной строки с кириллицей в cp1251 (`[F1]` вместо UTF-8) — сервер получал невалидный UTF-8 и
|
||||
падал 500 (`DecoderFallbackException` в `JsonElement.GetString`). Артефакт приёмочного скрипта, не API:
|
||||
реальные клиенты (Vue) шлют UTF-8. Наблюдение: API не валидирует UTF-8 строк тела явно (вне приёмки плана).
|
||||
5. **400 для не-JSON-объекта** — план жёстких ошибок тела не специфицирует («Ошибок-исключений нет» относится
|
||||
к полям); 400+detail — минимальная жёсткая граница протокола (в прототипе FastAPI на такое тело — 422).
|
||||
|
||||
## Concerns
|
||||
|
||||
- `SettingsEndpoints` резолвит scoped-сервис через `RequestServices` — отклонение от стиля `AuthEndpoints`
|
||||
(инъекция параметром), но там зависимость на системном `DealDbContext` и резолв вне сессии безопасен.
|
||||
Альтернатива (endpoint-filter до биндинга параметров) не гарантирует порядок «фильтр до резолва DI» — не стал
|
||||
полагаться на недокументированное поведение.
|
||||
- Невалидный UTF-8 в строках PATCH-тела даёт 500 (STJ валидирует строки лениво, `DecoderFallbackException`
|
||||
ловится не как `JsonException`). Вне acceptance; при желании — ловить в `ApplyPatchAsync`/валидировать тело.
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 6 curl-приёмка POST /api/ai/check на :5080 (план Task 6 L282–284, Ruling 7).
|
||||
# Сценарий: 401 без куки → login admin/admin → без ключа (дефолт deepseek) → «Не задан API-ключ» →
|
||||
# PATCH deepseek {baseUrl: http://127.0.0.1:59999 (недоступный порт), apiKey} → «Ошибка соединения» →
|
||||
# aiProvider=ollama (локальный) → «Локальный сервер …» → SSRF-гейт: ftp-схема baseUrl → ok:false →
|
||||
# logout → psql: enc: в aiConfigs. Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
CORE_DIR="C:/telbase/src/core"
|
||||
API_DIR="$CORE_DIR/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task6-jar.txt"
|
||||
OUT="/tmp/task6-out.txt"
|
||||
LOG="/tmp/task6-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторяемость).
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. POST /api/ai/check без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/check" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "без ключа" '"ok":false' '"message":"Не задан API-ключ"'
|
||||
check "статус deepseek (дефолты)" '"provider":"deepseek"' '"name":"DeepSeek"'
|
||||
check "base/model дефолтные" '"base":"https://api.deepseek.com"' '"model":"deepseek-v4-flash"'
|
||||
check "keySet false / маска пуста" '"keySet":false' '"keyMasked":""'
|
||||
check "local false" '"local":false'
|
||||
|
||||
echo
|
||||
echo "== 4. PATCH deepseek {baseUrl: http://127.0.0.1:59999, apiKey: sk-1234567890ab} =="
|
||||
echo " недоступный порт — ветка сетевого сбоя детерминирована (без внешней сети)"
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"aiConfigs":{"deepseek":{"baseUrl":"http://127.0.0.1:59999","apiKey":"sk-1234567890ab"}}}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200, keySet+маска" '[HTTP:200]' '"keySet":true' '"keyMasked":"sk-1…90ab"'
|
||||
|
||||
echo
|
||||
echo "== 4b. POST /api/ai/check — глубокое подключение недоступно → «Ошибка соединения» =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "check 200" '[HTTP:200]'
|
||||
check "ошибка соединения (префикс)" '"ok":false' '"message":"Ошибка соединения:'
|
||||
check "base из конфигурации" '"base":"http://127.0.0.1:59999"'
|
||||
check "ключ задан и замаскирован" '"keySet":true' '"keyMasked":"sk-1…90ab"'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"aiProvider":"ollama"}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "локальный провайдер ok" '[HTTP:200]' '"ok":true'
|
||||
check "сообщение локального сервера" '"message":"Локальный сервер «Ollama (локально)» (ping в проде)"'
|
||||
check "статус ollama" '"provider":"ollama"' '"local":true'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"aiProvider":"deepseek","aiConfigs":{"deepseek":{"baseUrl":"ftp://example.com"}}}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "недопустимая схема base URL" '[HTTP:200]' '"ok":false' '"message":"Недопустимый Base URL (ожидается http/https)"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 8. psql: строки settings; aiConfigs зашифрован (enc:) без открытого ключа =="
|
||||
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
|
||||
echo "--- проверка enc: ---"
|
||||
ENC_KEYS=$($PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';")
|
||||
echo "enc: найдено в: $ENC_KEYS"
|
||||
if echo "$ENC_KEYS" | grep -q "aiConfigs"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] aiConfigs хранит ключ в формате enc:"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] aiConfigs без enc:"
|
||||
fi
|
||||
if $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'aiConfigs';" | grep -qF "sk-1234567890ab"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] открытый ключ sk-1234567890ab найден в БД"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] открытого ключа в БД нет (только enc:)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 9. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task 6 — «ИИ-провайдеры и POST /api/ai/check (проверка подключения)» — отчёт
|
||||
|
||||
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты **98/98 PASS** (+10 новых
|
||||
`AiConnectionCheckerTests`); curl-приёмка на :5080 — **PASS=23 FAIL=0** (скрипт
|
||||
`task-6-curl-acceptance.sh`, лог `task-6-curl-acceptance.log`). Отчёт по плану
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 6 L265–285, Rulings 4/7/8;
|
||||
референс `settings_routes.py` L195–219, `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/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.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/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/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` (запись запросов, ответ/исключение по делегату). |
|
||||
| `.superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh`/`.log` | sh/log | curl-приёмка (23 проверки). |
|
||||
|
||||
## Решения
|
||||
|
||||
1. **Дизайн порта (Ruling 4).** `IAiConnectionChecker` — в модуле Settings (Application), потребляется
|
||||
только Settings-экраном; `IAiFacade` не заводится. Реализация — в Infrastructure/Integrations,
|
||||
ctor принимает `HttpClient`; DI — typed client `AddHttpClient<IAiConnectionChecker,
|
||||
AiConnectionChecker>` в `Program.cs` (фабрика, таймаут `RequestTimeoutSeconds = 12` с). Unit-тесты
|
||||
строят адаптер напрямую `new AiConnectionChecker(new HttpClient(stub))`.
|
||||
2. **Ветки 1:1** с `settings_routes.py` L195–219 (порядок кода прототипа: **локальный → ключ → HTTP**;
|
||||
иначе приёмка ollama без ключа дала бы «Не задан API-ключ» вместо «Локальный сервер…»):
|
||||
- локальный → `ok:true` «Локальный сервер «<name>» (ping в проде)» (HTTP не ходим);
|
||||
- нет ключа → `ok:false` «Не задан API-ключ»;
|
||||
- HTTP `<400` → «Подключение успешно»; `401/403` → «Ключ не принят (HTTP n) — проверьте ключ и
|
||||
доступ к модели»; иное (≥400) → «HTTP n — проверьте Base URL и модель»; таймаут/сеть →
|
||||
«Ошибка соединения: …». URL: `{base}/models`, Anthropic — `{base}/v1/models` + `x-api-key` +
|
||||
`anthropic-version` (Bearer для OpenAI-совместимых). `base` rstrip("/") как в прототипе.
|
||||
3. **`keyMasked` — маска `ai.py` mask_key L53–58** (источник Ruling 7 указывает на mask_key): пусто →
|
||||
`""`, len ≤ 8 → «x…», иначе «1234…5678». Отличие от маски Ruling 3 (len ≤ 8 — как есть) только для
|
||||
ключей ≤ 8 симв.; PATCH принимает ключи ≥ 8 — на практике расхождения нет (зафиксировано).
|
||||
4. **SSRF (см. preflight):** реализован allowlist **провайдеров** — checker отклоняет id вне
|
||||
фиксированного каталога `AiProviders` (ok:false «Провайдер не из списка разрешённых», HTTP не
|
||||
выполняется; в штатном потоке недостижимо — PATCH-гейт `aiProvider`/`aiConfigs`) + гейт схемы base
|
||||
URL: только абсолютный `http/https` (иначе ok:false «Недопустимый Base URL (ожидается http/https)»).
|
||||
Host-level рестрикции (совпадение baseUrl с дефолтом провайдера) **сознательно не вводил**: это
|
||||
сломало бы локальные серверы на LAN (Ollama/LM Studio), кастомные OpenAI-совместимые эндпоинты и
|
||||
ветку приёмки плана «недоступный хост → Ошибка соединения» (требует произвольного http(s)-хоста в
|
||||
конфиге). Триггер — только авторизованный владелец настроек; запрос — один GET /models.
|
||||
Продуктовая жёсткость (egress-фильтр/разрешённые хосты, проверка private-IP) — зафиксирована на
|
||||
прод-этап (за Cloudflare/шлюзом). Решение зафиксировано в `AiConnectionChecker` (XML-doc).
|
||||
5. **Эндпоинт без тела** (фронт `store.js checkAiConnection` → `POST /api/ai/check` без body): сервер
|
||||
читает **активную** конфигурацию (aiProvider + aiConfigs) из `ISettingsStore` и расшифровывает ключ
|
||||
`ISecretCipher` (1:1 с `ai_svc._cfg()`), как предписано планом; сборка запроса — приватный хелпер
|
||||
эндпоинта (дефолты `SettingsDefaults` + переопределения). Резолв scoped-зависимостей — через
|
||||
`RequestServices` **после** 401-гейта (паттерн SettingsEndpoints: иначе запрос без сессии — 500).
|
||||
6. **Общий HTTP-хелпер `EndpointResults`** (401/400 `{detail}`): третья Endpoints-файла — по заметке
|
||||
ревью T5; AuthEndpoints/SettingsEndpoints переведены, поведение не менялось (curl-приёмка T5-шагов
|
||||
воспроизводится в логе T6 шагов 1–2).
|
||||
|
||||
## Отклонения от задания/плана
|
||||
|
||||
- Имя файла эндпоинта: задание задачи говорит `Endpoints/AiEndpoints.cs`, план (Task 6 Files) —
|
||||
`AiCheckEndpoint.cs`. **План приоритетнее** — файл `AiCheckEndpoint.cs`, метод `MapAiCheckEndpoint`.
|
||||
- Файл `RatesEndpoints`/`AiCheckEndpoint` tag: `/ai/check` в прототипе живёт в роутере settings
|
||||
(tags=["settings"]) — tag `settings` (как в api-map §3.4).
|
||||
- В `progress.md` preflight T6/T7 отмечен SSRF-риск — закрыт в объёме dev-режима (см. п.4).
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
|
||||
dotnet test tests/Deal.Tests.Unit → всего: 98; сбой: 0; успешно: 98
|
||||
sh .superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh
|
||||
→ PASS=23 FAIL=0 (лог task-6-curl-acceptance.log)
|
||||
```
|
||||
|
||||
### curl-приёмка (сценарий и фактические ответы — в логе)
|
||||
|
||||
1. `POST /api/ai/check` без куки → **401** `{"detail":"Требуется авторизация"}`.
|
||||
2. login admin/admin → без ключа (дефолт deepseek) → **200** `{"ok":false,"message":"Не задан
|
||||
API-ключ","provider":"deepseek","name":"DeepSeek","base":"https://api.deepseek.com",
|
||||
"model":"deepseek-v4-flash","local":false,"keySet":false,"keyMasked":""}`.
|
||||
3. `PATCH aiConfigs.deepseek {baseUrl:"http://127.0.0.1:59999", apiKey:"sk-1234567890ab"}` (недоступный
|
||||
порт — сетевой сбой детерминирован, без внешней сети) → check → **200** `{"ok":false,"message":
|
||||
"Ошибка соединения: Подключение не установлено, т.к. конечный компьютер отверг запрос на
|
||||
подключение. (127.0.0.1:59999)",…,"base":"http://127.0.0.1:59999","keySet":true,
|
||||
"keyMasked":"sk-1…90ab"}`.
|
||||
4. `PATCH aiProvider=ollama` → check → **200** `{"ok":true,"message":"Локальный сервер «Ollama
|
||||
(локально)» (ping в проде)","provider":"ollama",…,"local":true}`.
|
||||
5. `PATCH aiProvider=deepseek + baseUrl:"ftp://example.com"` → check → **200** ok:false
|
||||
«Недопустимый Base URL (ожидается http/https)» (SSRF-гейт схемы).
|
||||
6. logout 200. psql до очистки: строки `aiConfigs`/`aiProvider`; apiKey в `aiConfigs` — `enc:…`,
|
||||
открытого ключа в БД нет. После прогона таблица `settings` очищена (0 строк), процесс остановлен.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Модуль не узнаёт имя провайдера из запроса: checker резолвит `Name` по каталогу `AiProviders`
|
||||
(в запросе имени нет — поля DTO заданы планом). Для неизвестного id (ручное вмешательство в БД)
|
||||
`Name` = id; в штатном потоке недостижимо.
|
||||
- Ветка таймаута не покрыта unit-тестом (требует реальной задержки); проверена логикой
|
||||
`catch (OperationCanceledException) when (!ct.IsCancellationRequested)`. «Ошибка соединения» на
|
||||
timeout соответствует прототипу (ловит все исключения).
|
||||
- Расхождение маски короткого ключа (≤8) между `/api/ai/check` (mask_key: «x…») и GET/PATCH settings
|
||||
(Ruling 3: как есть) — см. п.3 «Решений»; влияет только на ключи ≤ 8 симв., которых PATCH не создаёт.
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 7 curl-приёмка границы промптов на :5080 (план Task 7 L303–306).
|
||||
# Сценарий: 401 без куки → login admin/admin → GET: дефолтный aiPrompt с {domain}/{keywords},
|
||||
# myPrompts пуст → PATCH aiPrompt с плейсхолдерами → GET возвращает тот же текст →
|
||||
# PATCH myPrompts 3 записи (id/name/description/prompt) → GET отдаёт их (camelCase) →
|
||||
# psql: строки settings → logout → GET после logout = 401. Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task7-jar.txt"
|
||||
OUT="/tmp/task7-out.txt"
|
||||
LOG="/tmp/task7-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PROMPT_AI="ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"
|
||||
|
||||
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"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
check_count() {
|
||||
# $1 — описание; $2 — ожидаемое число; $3 — подстрока (по одному совпадению на элемент)
|
||||
desc=$1
|
||||
expected=$2
|
||||
pattern=$3
|
||||
actual=$(grep -o -F -- "$pattern" "$OUT" | wc -l)
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc (нашлось $actual)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — ожидалось $expected, нашлось $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/settings без куки — ожидаем 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/settings — дефолты промптов из data.js, myPrompts пуст =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "дефолтный aiPrompt (маркеры data.js)" '"aiPrompt":"Ты — классификатор входящих сообщений' '{domain}' '{keywords}'
|
||||
check "myPrompts пуст" '"myPrompts":[]'
|
||||
|
||||
echo
|
||||
echo "== 4. PATCH aiPrompt с плейсхолдерами {domain}/{keywords} =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200, текст на месте" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
|
||||
|
||||
echo
|
||||
echo "== 4b. GET /api/settings — тот же текст промпта (с плейсхолдерами как есть) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200, тот же текст" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
|
||||
|
||||
echo
|
||||
echo "== 5. PATCH myPrompts — 3 записи (id/name/description/prompt) =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200" '[HTTP:200]'
|
||||
check "все три записи в ответе" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
|
||||
check "поля camelCase" '"name":"Кровля — строгий"' '"description":"Только явные заказы на кровлю"'
|
||||
check "текст промпта доехал" '"prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"'
|
||||
|
||||
echo
|
||||
echo "== 5b. GET /api/settings — «Мои промпты» на месте =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200" '[HTTP:200]'
|
||||
check "три записи" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
|
||||
check_count "myPrompts содержит 3 элемента" 3 '"id":"pp_'
|
||||
check "описания на месте" '"description":"Вакансии разработчиков"'
|
||||
|
||||
echo
|
||||
echo "== 6. psql: строки settings (aiPrompt, myPrompts) =="
|
||||
$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';")
|
||||
echo "myPrompts value_json: $MYPROMPTS_JSON"
|
||||
if echo "$MYPROMPTS_JSON" | grep -q "pp_aaaa1111"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] myPrompts сохранены в KV settings"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] myPrompts не найдены в KV settings"
|
||||
fi
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 8. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1 @@
|
||||
{"aiPrompt":"ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"}
|
||||
@@ -0,0 +1,5 @@
|
||||
{"myPrompts":[
|
||||
{"id":"pp_aaaa1111","name":"Кровля — строгий","description":"Только явные заказы на кровлю","prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"},
|
||||
{"id":"pp_bbbb2222","name":"Дизайн — гибкий","description":"","prompt":"Ты — классификатор дизайна: {domain}, маркеры: {keywords}"},
|
||||
{"id":"pp_cccc3333","name":"Найм IT","description":"Вакансии разработчиков","prompt":"Ты — классификатор найма IT: {domain}, маркеры: {keywords}"}
|
||||
]}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Task 7 — «Промпты и „Мои промпты“: интеграционная проверка границы с фронтом» — отчёт
|
||||
|
||||
Статус: **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 L287–308, Ruling 8; референс `ai.py` L63–77).
|
||||
|
||||
## Граница «библиотека промптов — фронт» (проверка кода, новых эндпоинтов НЕТ)
|
||||
|
||||
- Библиотека промптов **полностью фронтовая**: `data.js` `PROMPT_LIBRARY` L180–200 (19 шаблонов, категории L167–176, готовые тексты вариантов поведения L155–165) + `buildClassifierPrompt` L148–151. `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 подтверждена.
|
||||
- Формат «Моих промптов» 1:1: фронт шлёт `{id: "pp_…", name, description, prompt}` (store.js L1691–1705), бэк — `MyPromptDto(Id, Name, Description, Prompt)` → наружу camelCase `id/name/description/prompt`.
|
||||
|
||||
## Сверка DefaultPrompts с data.js (построчно, включая хвостовые \n)
|
||||
|
||||
Полная сверка выполнена скриптом `task-7-sverka-prompts.mjs` (извлекает шаблоны из data.js и raw-литералы DefaultPrompts.cs, нормализует `\r\n→\n` и `TrimEnd('\n')`) и **закреплена тестом** `PromptDefaultsTests` (читает data.js из репозитория, сравнивает построчно с сообщением о расхождении).
|
||||
|
||||
| Промпт | data.js | DefaultPrompts.cs | Итог |
|
||||
|---|---|---|---|
|
||||
| `aiPrompt` (DEFAULT_AI_PROMPT L94–114) | 21 строка, 2858 симв., без хвостового `\n` | 21 строка, 2858 симв. | идентичны |
|
||||
| `cardPrompt` (DEFAULT_AI_CARD_PROMPT L116–123) | 8 строк, 1363 симв. | 8 строк, 1363 симв. | идентичны |
|
||||
| `aiFilterPrompt` (DEFAULT_AI_FILTER_PROMPT L125–141) | 17 строк, 686 симв. | 17 строк, 686 симв. | идентичны |
|
||||
|
||||
**Расхождений с data.js не найдено — правки DefaultPrompts.cs не потребовались.** В `constants.py` L63–141 те же тексты, но с расхождениями формулировок и разбивки на строки (напр., пункт «4. title»: constants.py L79–80 — «без эмодзи, хэштегов и знаков препинания», а в data.js L103 — «без эмодзи, хэштегов, markdown-разметки…»); источник дефолтов — data.js (фронт), поэтому дефолты не менялись.
|
||||
|
||||
## Изменения
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `src/core/Deal.Modules.Settings/Application/PromptFiller.cs` | create | Подстановка `{domain}`/`{keywords}` в текст промпта (чистая функция, аналог `ai.fill_prompt` L63–77): пустой 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). |
|
||||
|
||||
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-7-curl-acceptance.sh/.log; тела — UTF-8-файлы *.json)
|
||||
|
||||
1. GET без куки → 401 `{"detail":"Требуется авторизация"}`.
|
||||
2. login → дефолты: `aiPrompt` из data.js (маркеры и `{domain}`/`{keywords}`), `myPrompts:[]`.
|
||||
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` с записями.
|
||||
5. logout → GET снова 401. Итог: **PASS=19 FAIL=0**; dev-БД очищена, сервер остановлен.
|
||||
|
||||
## Concerns / замечания
|
||||
|
||||
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).
|
||||
3. `PromptFiller` — осознанно чистая функция с параметрами `(prompt, domain, keywords)`, в отличие от `ai.py fill_prompt`, который сам читает store: модуль Settings не получает зависимость на хранилище внутри утилиты (YAGNI до этапа 6).
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
|
||||
dotnet test Deal.sln → всего 113, пройдено 113, пропущено 0
|
||||
node task-7-sverka-prompts.mjs → ИТОГ: 3/3 совпали (построчно)
|
||||
sh task-7-curl-acceptance.sh → PASS=19 FAIL=0 (лог task-7-curl-acceptance.log)
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
// Сверка текстов DefaultPrompts.cs (C#) с data.js (фронт — высший авторитет, план L47).
|
||||
// Извлекает три DEFAULT_* из data.js (между обратными кавычками шаблона) и тела raw-string
|
||||
// литералов DefaultPrompts.cs, нормализует \r\n→\n и завершающие \n (семантика Normalize),
|
||||
// сравнивает построчно и печатает расхождения. Запуск: node task-7-sverka-prompts.mjs
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const DATA_JS = 'C:/telbase/src/frontend/src/data.js'
|
||||
const CS_FILE = 'C:/telbase/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs'
|
||||
|
||||
const PAIRS = [
|
||||
['DEFAULT_AI_PROMPT', 'DefaultAiPrompt'],
|
||||
['DEFAULT_AI_CARD_PROMPT', 'DefaultCardPrompt'],
|
||||
['DEFAULT_AI_FILTER_PROMPT', 'DefaultAiFilterPrompt'],
|
||||
]
|
||||
|
||||
function extractDataJs(source, exportName) {
|
||||
const re = new RegExp('export const ' + exportName + ' = `([\\s\\S]*?)`', 'm')
|
||||
const m = source.match(re)
|
||||
if (!m) throw new Error('data.js: блок ' + exportName + ' не найден')
|
||||
return m[1].replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function extractCs(source, propName) {
|
||||
const normalized = source.replace(/\r\n/g, '\n')
|
||||
const re = new RegExp(
|
||||
'public static readonly string ' + propName + ' = Normalize\\(\\s*"""\\n([\\s\\S]*?)\\n(\\s*)"""\\);',
|
||||
)
|
||||
const m = normalized.match(re)
|
||||
if (!m) throw new Error('DefaultPrompts.cs: блок ' + propName + ' не найден')
|
||||
const indent = m[2]
|
||||
const lines = m[1].split('\n').map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
|
||||
let text = lines.join('\n').replace(/\r/g, '')
|
||||
text = text.replace(/\n+$/, '') // TrimEnd('\n') как в Normalize
|
||||
return text
|
||||
}
|
||||
|
||||
const js = readFileSync(DATA_JS, 'utf8')
|
||||
const cs = readFileSync(CS_FILE, 'utf8')
|
||||
|
||||
let totalFail = 0
|
||||
for (const [jsName, csProp] of PAIRS) {
|
||||
const expected = extractDataJs(js, jsName)
|
||||
const actual = extractCs(cs, csProp)
|
||||
const expLines = expected.split('\n')
|
||||
const actLines = actual.split('\n')
|
||||
|
||||
console.log(`=== ${jsName} <-> ${csProp} ===`)
|
||||
console.log(` data.js: ${expLines.length} строк, ${expected.length} симв.`)
|
||||
console.log(` C#: ${actLines.length} строк, ${actual.length} симв.`)
|
||||
console.log(` хвостовой \\n: data.js=${expected.endsWith('\n')} C#=${actual.endsWith('\n')}`)
|
||||
|
||||
if (expected === actual) {
|
||||
console.log(' [OK] тексты идентичны')
|
||||
continue
|
||||
}
|
||||
totalFail++
|
||||
console.log(' [DIFF] есть расхождения:')
|
||||
const n = Math.max(expLines.length, actLines.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = expLines[i] ?? '<нет строки>'
|
||||
const a = actLines[i] ?? '<нет строки>'
|
||||
if (e !== a) {
|
||||
console.log(` строка ${i + 1}:`)
|
||||
console.log(` data.js: ${JSON.stringify(e)}`)
|
||||
console.log(` C#: ${JSON.stringify(a)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(totalFail === 0 ? '\nИТОГ: 3/3 совпали' : `\nИТОГ: расхождений в ${totalFail} блоках`)
|
||||
process.exit(totalFail === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 8 curl-приёмка /api/rates* на :5080 (план Task 8 L329–331; Ruling 6).
|
||||
# Сценарий: 401 без куки → login admin/admin → GET /api/rates (нет кэша: source mock, updatedAt null) →
|
||||
# GET /api/settings не содержит внутренний ratesCache → PATCH {"rateSource":"mock"} →
|
||||
# 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)
|
||||
# → logout → GET 401. Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task8-jar.txt"
|
||||
OUT="/tmp/task8-out.txt"
|
||||
LOG="/tmp/task8-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
check_not() {
|
||||
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
|
||||
desc=$1
|
||||
pattern=$2
|
||||
if grep -qF -- "$pattern" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не ожидалось: $pattern"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc (отсутствует: $pattern)"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/rates без куки — ожидаем 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/rates — кэша нет: мок-курсы, source mock, updatedAt null =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200, дефолт-мок" '[HTTP:200]' '"base":"RUB"' '"source":"mock"' '"updatedAt":null'
|
||||
check "курсы на месте" '"RUB":1' '"USD":92.5' '"EUR":99.9' '"USDT":92.5'
|
||||
|
||||
# GET при пустом кэше и rateSource=cbr (дефолт) мог запустить фоновый refresh ЦБ — даём ему завершиться,
|
||||
# чтобы он не перетёр мок-кэш следующих шагов (интернет в окружении есть).
|
||||
echo "== 3b. Пауза 4 с: фоновый refresh (если запустился) успевает завершиться =="
|
||||
sleep 4
|
||||
|
||||
echo
|
||||
echo "== 4. GET /api/settings — внутренний ключ ratesCache НЕ публикуется =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET settings 200" '[HTTP:200]'
|
||||
check_not "нет ratesCache в public-снимке" 'ratesCache'
|
||||
check "rateSource в снимке есть (дефолт/текущий)" '"rateSource":"'
|
||||
|
||||
echo
|
||||
echo "== 5. PATCH {\"rateSource\":\"mock\"} (фоновый refresh по Ruling 6) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200, rateSource=mock" '[HTTP:200]' '"rateSource":"mock"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
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_not "updatedAt не null после refresh" '"updatedAt":null'
|
||||
|
||||
echo
|
||||
echo "== 7. GET /api/rates — тот же кэш (source mock, updatedAt на месте) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200, тот же кэш" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
|
||||
check_not "updatedAt не null (кэш сохранён)" '"updatedAt":null'
|
||||
|
||||
echo
|
||||
echo "== 8. psql: строка ratesCache в settings (внутренний KV-ключ) =="
|
||||
$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';")
|
||||
echo "ratesCache value_json: $CACHE_JSON"
|
||||
if echo "$CACHE_JSON" | grep -q '"updatedAtMs"' && echo "$CACHE_JSON" | grep -q '"source":"mock"'; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] ratesCache сохранён как {rates, source, updatedAtMs}"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] ratesCache не в форме Ruling 6: $CACHE_JSON"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 9. Реальный источник cbr: PATCH rateSource=cbr + POST refresh =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"rateSource":"cbr"}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "refresh cbr 200 ok:true (ЦБ доступен)" '[HTTP:200]' '"ok":true'
|
||||
check "rates.source cbr, base RUB" '"source":"cbr"' '"base":"RUB"'
|
||||
check_not "updatedAt не null после cbr-refresh" '"updatedAt":null'
|
||||
|
||||
echo
|
||||
echo "== 9b. GET /api/rates — кэш cbr на месте =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "GET 200, source cbr" '[HTTP:200]' '"source":"cbr"' '"base":"RUB"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 11. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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 (включая реальный запрос к ЦБ РФ).
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 8 L308–331, Ruling 6 L83–89, Ruling 1; референс `rates.py` целиком, `constants.py` L41–52).
|
||||
|
||||
## Реализация
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `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/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 (L86–103). Повреждённые строки хранилища → мягкий дефолт (как 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.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/SettingsEndpoints.cs` | modify | PATCH с полем `rateSource` → `RatesRefreshScheduler.Schedule()` (1:1 L188–189: `if body.get("rateSource")`); ссылка на Task 8 в доке класса. |
|
||||
| `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>()`. |
|
||||
| `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 на чистой БД отдал мок.
|
||||
- **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.
|
||||
- **Фоновые обновления** — 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). Тенант не управляет адресом.
|
||||
- **Фронт**: GET /api/rates (boot store.js L571–581), POST /api/rates/refresh (кнопка «Обновить курсы», refreshRates L1843–1848), PATCH rateSource (schedulePersist) — всё с вкладки «Валюта и курсы» SettingsView; Ruling 8 «только для Settings-экрана» подтверждена, эндпоинты фронтом используются (пункт 4 задания).
|
||||
- **`ConvertAmount`** — чистая функция с параметром `rates` (Ruling 6: в этапе 2 только чистый ConvertAmount; пересчёт карточек — этап 3).
|
||||
|
||||
## Тесты (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).
|
||||
- `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)
|
||||
|
||||
1. GET /rates без куки → 401 `{"detail":"Требуется авторизация"}`.
|
||||
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` на месте.
|
||||
4. PATCH `{"rateSource":"mock"}` → 200 `"rateSource":"mock"`.
|
||||
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).
|
||||
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 свободен).
|
||||
|
||||
## Concerns / замечания
|
||||
|
||||
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>`); фронт показывает «мок-курсы» только когда кэша ещё не было.
|
||||
3. **Typed client `AddHttpClient<IRatesSource, CbrRateSource>`** регистрируется transient (как IAiConnectionChecker, Task 6), план писал «scoped»: внутри scope запроса/фоновой работы поведение эквивалентно (клиент живёт в рамках scope, сбой-безопасно); расхождение формулировок зафиксировано.
|
||||
4. **Межтенантный дебаунс** фонового refresh (1 на процесс) осознан: обновление редкое (≤1/6 ч на тенант), худший случай — отложенный на секунды refresh второго тенанта.
|
||||
5. 401-ветки эндпоинтов проверены curl (без куки/после logout), юнит-инфраструктуры хостинга Api в проекте нет (как и для прошлых задач) — ветки тела ответа покрыты на уровне сервисов + wire-тест RatesDto.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
|
||||
dotnet test Deal.sln → всего 148, пройдено 148, пропущено 0 (было 113, +35)
|
||||
sh task-8-curl-acceptance.sh → PASS=22 FAIL=0 (лог task-8-curl-acceptance.log)
|
||||
```
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env sh
|
||||
# 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,
|
||||
# service.ready:false, eval обнулён, stats.outbox:0) → PATCH mlEnabled=false → статус enabled:false →
|
||||
# PATCH true → predict {"text":"x"} → 400 «Введите текст» → predict с текстом (UTF-8 файл) →
|
||||
# take:false/label:null/scores:{} → reset {ok:true} → candidates {items:[]} → apply 404
|
||||
# «Исходное сообщение не найдено» → psql: нет таблиц ml_outbox/learning_log → logout → 401.
|
||||
# Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
|
||||
JAR="/tmp/task9-jar.txt"
|
||||
OUT="/tmp/task9-out.txt"
|
||||
LOG="/tmp/task9-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
check_not() {
|
||||
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
|
||||
desc=$1
|
||||
pattern=$2
|
||||
if grep -qF -- "$pattern" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не ожидалось: $pattern"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc (отсутствует: $pattern)"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$APP_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings пуста"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/ml/status без куки — ожидаем 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/ml/status" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "status 200" '[HTTP:200]'
|
||||
check "enabled:true (дефолт mlEnabled)" '"enabled":true'
|
||||
check "reachable:true" '"reachable":true'
|
||||
check "service.ready:false" '"service":{"ready":false'
|
||||
check "service.classes {} и learned:0" '"classes":{},"learned":0'
|
||||
check "service.eval обнулён" '"eval":{"count":0,"correct":0,"accuracy":0'
|
||||
check "stats.ml/ai 0" '"stats":{"ml":0,"ai":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'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"mlEnabled":false}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200 mlEnabled:false" '[HTTP:200]' '"mlEnabled":false'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
|
||||
check "status enabled:false" '[HTTP:200]' '"enabled":false'
|
||||
check_not "нет service в ответе" '"error"'
|
||||
|
||||
echo
|
||||
echo "== 4b. PATCH {\"mlEnabled\":true} — возврат дефолта =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
|
||||
-H "Content-Type: application/json" -d '{"mlEnabled":true}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "PATCH 200 mlEnabled:true" '[HTTP:200]' '"mlEnabled":true'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"text":"x"}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "predict 400" '[HTTP:400]' '"detail":"Введите текст"'
|
||||
|
||||
echo
|
||||
echo "== 6. POST /api/ml/predict с текстом (UTF-8 из файла) — «не уверен», все поля =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "predict 200" '[HTTP:200]'
|
||||
check "text эхом (кириллица без \\u-экранов)" 'Python backend на fastapi, бот в телеграм, удалённо, сделка'
|
||||
check "take:false, label:null, scores:{}" '"take":false,"label":null,"scores":{}'
|
||||
check "hits:0, ready:false" '"hits":0,"ready":false'
|
||||
check "margin:null, terms:[], type:null" '"margin":null,"terms":[],"type":null'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "reset 200" '[HTTP:200]' '{"ok":true}'
|
||||
check_not "нет поля error при успехе" '"error"'
|
||||
|
||||
echo
|
||||
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" \
|
||||
-H "Content-Type: application/json" -d '{"dialogId":"chan_test","limit":10}' > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "candidates 200" '[HTTP:200]' '{"items":[]}'
|
||||
|
||||
echo
|
||||
echo "== 9. POST /api/ml/apply — 404 «Исходное сообщение не найдено» =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "apply 404" '[HTTP:404]' '"detail":"Исходное сообщение не найдено"'
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
if [ -s "$OUT" ]; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] в схеме тенанта есть лишние таблицы (ml_outbox/learning_log?): $(tr '\n' ' ' < "$OUT")"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] в схеме тенанта только settings (и история миграций)"
|
||||
fi
|
||||
|
||||
echo
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout status 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout reset 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1 @@
|
||||
{"text":"Python backend на fastapi, бот в телеграм, удалённо, сделка"}
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 9 L333–367, Ruling 4 L71–75, Ruling 5 L76–82; референс `ml_routes.py` L66–91/L112–171, `ml_client.py` L101–150, `mlservice/model.py` predict/status; фронт `MLPanel.vue` + `store.js` applyMlStatus L487–502).
|
||||
|
||||
## Реализация
|
||||
|
||||
| Файл | Тип | Содержание |
|
||||
|---|---|---|
|
||||
| `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 L325–345. |
|
||||
| `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()` L138–150. |
|
||||
| `Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs` | create | Тело GET /api/ml/status `{Enabled, Service, Reachable, Stats}` 1:1 `ml_routes.py` L70–75 (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/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.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.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/Http/EndpointResults.cs` | modify | Добавлен `NotFound(detail)` (404 + {detail}, Ruling 10). |
|
||||
| `Deal.Api/Program.cs` | modify | `AddDealIntegrations()` + `app.MapMlEndpoints()`. |
|
||||
| `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»-часть на сетевой вызов).
|
||||
- **Deal.Contracts остался без зависимостей**: в контракте ML нет TenantId/SharedKernel — ссылку на SharedKernel добавлять не потребовалось (проверено: Contracts.csproj без ProjectReference; см. Ruling 4). Исключение контрактных моделей покрыто «модель в отдельном файле» (1 тип = 1 файл).
|
||||
- **Два 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, L352–353). ml/learn, ml/flush НЕ реализованы (фронт не вызывает — api-map п.9 L399).
|
||||
- **Predict-ответ** — анонимный объект эндпоинта `{text, ...flatten}` (1:1 с python `{"text": text[:200], **result}`); text эхом = полный trim-нутый ввод (усечение только >200 символов, как `text[:200]`).
|
||||
- **Очередь обучения**: reset не пишет/не чистит KV-счётчики (в прототипе чистится только ml_outbox — таблицы в этапе 2 нет, Ruling 5 L81–82); счётчики mlDecisions/aiDecisions в статусе читаются, владельцы записи — этапы 3/4/6.
|
||||
- **Ошибки тела**: predict с текстом <2 символов после trim — 400 `{"detail":"Введите текст"}` (ровно как FastAPI); повреждённые KV-строки (mlEnabled/mlDecisions) — мягкий дефолт (как SettingsService); mlEnabled=false даёт enabled:false, отсутствие/не-bool — true (семантика `is not False`).
|
||||
|
||||
## Тесты (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).
|
||||
|
||||
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-9-curl-acceptance.sh/.log, тело predict — UTF-8 файл task-9-predict.json)
|
||||
|
||||
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).
|
||||
3. PATCH `{"mlEnabled":false}` → status `enabled:false`; PATCH true → `enabled:true` (KV живьём).
|
||||
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`.
|
||||
6. reset → `{"ok":true}` (ключа error нет); candidates → `{"items":[]}`; apply → 404 «Исходное сообщение не найдено».
|
||||
7. psql: в схеме тенанта таблиц ml_outbox/learning_log НЕТ (только settings + история миграций).
|
||||
8. logout → status/reset 401. Итог: **PASS=31 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
|
||||
|
||||
## Concerns / замечания
|
||||
|
||||
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 только с сессией — приемлемо.
|
||||
3. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/404/400-ветки и wire эндпоинтов покрыты curl-приёмкой; юнит-уровень — LocalMlClient + wire-тесты DTO.
|
||||
4. **Contracts без TenantId** — ссылка на SharedKernel не понадобилась (см. выше); если этап 6 добавит в контракт tenant-параметры — ссылка появится тогда.
|
||||
|
||||
## Проверки
|
||||
|
||||
```
|
||||
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
|
||||
dotnet test Deal.sln → всего 161, пройдено 161, пропущено 0 (было 148, +13)
|
||||
sh task-9-curl-acceptance.sh → PASS=31 FAIL=0 (лог task-9-curl-acceptance.log)
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md
|
||||
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
|
||||
|
||||
## Todos
|
||||
- Task 1: complete (review clean; миграция TenantKanban применена, 175 PASS). Отчёт: task-1-report.md. Note: сущность MlOutboxEntity (не Item) — сверить в T4/T5.
|
||||
- [x] Task 1: Миграция TenantKanban
|
||||
- Task 2: complete (review clean; 176 PASS; DTO/порт/реестр, модуль чист). Отчёт: task-2-report.md.
|
||||
- [x] Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр
|
||||
- Task 3: complete (review clean; 245 PASS; ColumnRules/AmountParser/BudgetNormalizer 1:1 с rules.py/ai.py). Отчёт: task-3-report.md.
|
||||
- [x] Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты
|
||||
- Task 4: complete (build 0/0; 245 PASS; KanbanStore на TenantDbContext — все 25 методов порта; DI в AddDealPersistence; функциональная проверка на дефолтном тенанте + psql; схема очищена для Task 8). Отчёт: task-4-report.md.
|
||||
- Task 4: complete (review clean; 245 PASS; delete board → карточки в inbox новыми — 1:1). Отчёт: task-4-report.md. Note: guard «в ту же колонку» даст T7.
|
||||
- [x] Task 4: EF-адаптер KanbanStore + DI
|
||||
- Task 5: complete (build 0/0; 255 PASS; PushAsync в контракте; LocalMlClient через порт IMlLearningStore (Kanban) — outbox/learning/status/reset; dev-проверка 24/24 на real Postgres). Отчёт: task-5-report.md.
|
||||
- Task 5: complete (review clean; 255 PASS; LocalMlClient на портах). Отчёт: task-5-report.md.
|
||||
- [x] Task 5: IMlClient.PushAsync + LocalMlClient
|
||||
- Task 6: complete (build 0/0; 282 PASS; BoardsService (list/create/patch/delete/reorder/colState) на портах IKanjStore+ISettingsStore; списки досок без counts — фронт берёт их из /api/leads; PrefixId вынесен из T7). Отчёт: task-6-report.md.
|
||||
- Task 6: complete (review clean; 282 PASS). Отчёт: task-6-report.md. Note для T8: валидация 400 на null-name; rules null-устойчивость.
|
||||
- [x] Task 6: BoardsService — колонки-доски и colState + unit-тесты
|
||||
- Task 7: complete (build 0/0; 326 PASS: 282 → +44; CardsService (list/get/move/trash/restore/delete/clear_col/mark_seen/comment/counts/search) на портах IKanjStore+ISettingsStore+IMlClient; matchHits через ColumnRules; журнал CardMoves + push (обучение всегда); CardMapper не создавался — маппинг живёт в адаптере T4 (расхождение плана зафиксировано)). Отчёт: task-7-report.md.
|
||||
- Task 7: complete (review clean; 326 PASS). Отчёт: task-7-report.md. Note: contacts-fallback doc≠code — поправить doc или реализовать позже.
|
||||
- [x] Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts
|
||||
- Task 8: complete (build 0/0; 326 PASS; BoardsEndpoints/LeadsEndpoints + RequestModels; DI map в Program.cs; curl-приёмка 43/43 PASS — boards/columns/leads/search + 400/404-ветки). Отчёт: task-8-report.md. Note для T9+: карточки в этапе 3 создаёт демо T13 — полный цикл move/trash/restore/clear-col примем в T15.
|
||||
- Task 8: complete (review clean; 326 PASS; curl 43/43). Отчёт: task-8-report.md.
|
||||
- [x] Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка
|
||||
- Task 9: complete (review clean; 332 PASS; curl 12/12). Отчёт: task-9-report.md. Note: для тестов ASP.NET-типов позже — FrameworkReference/Mvc.Testing.
|
||||
- [x] Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status
|
||||
- Task 10: complete (build 0/0; 342 PASS: 332 → +10 StorageTickServiceTests на fake; StorageTickService (модуль, TickAsync) + POST /api/admin/tick (storage+reminders/pipeline/queue заглушки + SSE-toast 1:1) + /admin/fts/rebuild заглушка {ok,ready}; curl 12/12). Отчёт: task-10-report.md.
|
||||
- Task 10: complete (review clean; 342 PASS; curl 12/12). Отчёт: task-10-report.md.
|
||||
- [x] Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast
|
||||
- Task 11: complete (build 0/0; 348 PASS: 342 → +6 — StorageTickSchedulerTests на реальном DI+fake (обход всех тенантов по scope/SetTenant/Reset, тост в канал каждого, изоляция падения тика тенанта и реестра) + StorageToastPublisherTests (тексты/иконки 1:1); StorageTickScheduler (IHostedService, Timer 30 с, guard Interlocked, graceful stop) + StorageToastPublisher (общий для /admin/tick и цикла); curl 10/10 — автоархив фоновым циклом без ручного tick, лог без ошибок, T10-тик не сломан). Отчёт: task-11-report.md.
|
||||
- Task 11: complete (review clean; 348 PASS; curl 10/10). Отчёт: task-11-report.md.
|
||||
- Task 11: complete (review clean; 348 PASS; curl 10/10). Отчёт: task-11-report.md.
|
||||
- [x] Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам
|
||||
- Task 12: complete (build 0/0; 369 PASS: 348 → +21 — ConversionRecomputerTests (12: mock-курсы USD→RUB,
|
||||
targetCurrency смена/дефолт, USDT=USD, conversionOn=false, archive/trash/taken исключены, нет курса/нет
|
||||
кэша/битый кэш, событие порта, идемпотентность), RatesServiceTests +4 и SettingsServiceTests +5 — триггеры
|
||||
listener'ов (refresh после записи кэша, PATCH targetCurrency/conversionOn после сохранения; сбой ЦБ/чужие
|
||||
ключи/JSON-null не оповещают); IRatesChangedListener (Settings) + ConversionRecomputer/RateTable (Kanban),
|
||||
регистрация в AddKanbanModule; dev-приёмка 14/14 — карточка 100 USD → conv RUB/USD/EUR по триггерам,
|
||||
archive не тронута). Отчёт: task-12-report.md.
|
||||
- Task 12: complete (review clean; 369 PASS). Отчёт: task-12-report.md. Note: конвертер продублирован (RateTable vs RatesService) — свести позже.
|
||||
- [x] Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener
|
||||
- Task 13: complete (build 0/0; 380 PASS: 369 → +11 DemoLeadFactoryTests; DemoLeadFactory (модуль, демо-пул 1:1 + создание карточки через IKanjStore.Add с BudgetNormalizer/контактами/конверсией + состаривание) + DemoEndpoints (POST /api/demo/simulate-lead, /age-lead; 401 → флаг DemoOptions/DEAL_DEMO → 404 «Демо-режим отключён»; SSE new_lead+toast, age+тик+toast) + DemoOptions/appsettings; порт +2 (GetOldestBoardCardAsync/UpdateReceivedAtAsync — age-lead gap-fill); curl 40/40 — полный §4.1, DESC, автоархив psql, restore/move/trash-цикл, SSE 3×new_lead+тосты, Production без флага 404). Отчёт: task-13-report.md.
|
||||
- Task 13: complete (review clean; 380 PASS; curl 40/40). Отчёт: task-13-report.md.
|
||||
- [x] Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
|
||||
- Task 13: complete (review clean; 380 PASS; curl 40/40). Отчёт: task-13-report.md.
|
||||
- [x] Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
|
||||
- Task 14: complete (review clean; 410 PASS; curl 32/32). Отчёт: task-14-report.md.
|
||||
- [x] Task 14: ИИ-предложения — IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords
|
||||
- Task 14: complete (build 0/0; 410 PASS: 380 → +30 — SuggestHeuristicsTests +14 (частотные темы/стоп-слова/группы ≥2/лимит 4/окно MAX_TEXT/похожесть с досками/детерминизм, маркеры keywords), LocalColumnSuggesterTests +11 (мало карточек/кулдаун/создание suggested+note+раскладка matchHits/похожие колонки/keywords выборка без trash-archive), SuggestResultDtosTests +5 (wire 1:1); порт IColumnSuggester + DTO в Contracts, чистый SuggestHeuristics (Kanban), адаптер LocalColumnSuggester (Infrastructure, кулдаун KV lastSuggestAt 20 мин), AiSuggestEndpoints (POST /api/ai/suggest-columns|keywords, 401-гейт, SSE-toast при ok); curl 32/32 ×2 — пустой inbox ok:false, 6 демо → {ok:true, created≥1} + suggested-доски c note/карточками + PATCH suggested:false + повтор → cooldown + keywords {ok,…} + logout 401). Отчёт: task-14-report.md.
|
||||
- Task 15: complete (review pending). Отчёт: task-15-report.md. Артефакты: task-15-curl-acceptance.sh + .log
|
||||
(build 0/0; 410 PASS; curl :5080 PASS=94 FAIL=0 — сквозной сценарий: 401/boot-группы/login, SSE 14×new_lead+
|
||||
toasts, демо ×14, доски+rules+move matchHits, trash/restore/comment/mark-col-seen/search, suggest-columns
|
||||
(created≥1, suggested-доски с note/карточками) → PATCH suggested:false, suggest-keywords, age-lead → автоархив,
|
||||
admin/tick, StorageTickScheduler (автоархив без ручного tick, 25 с), conv 9250 RUB/100 USD/92.59 EUR + restore
|
||||
настроек, logout→401; dev-БД очищена — схема/таблицы и настройки конверсии остались; техдок §11/§13 и roadmap
|
||||
обновлены: этап 3 — выполнено с ограничениями 4/5/6).
|
||||
- Task 15: complete (review clean; сквозная приёмка 94/94). Отчёт: task-15-report.md.
|
||||
- **Этап 3 завершён**: финальное whole-scope ревью ✅ (build 0/0, 410 PASS, миграция TenantKanban применена, boot() фронта удовлетворён). Minor: (1) косметика acceptance-скрипта; (2) слабые ассерты скрипта (не продукта); (3) кириллица в curl-query — MSYS.
|
||||
- [x] Task 15: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
## Pre-flight scan (краткий)
|
||||
| Пара | Производит/потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T1 → T4 | миграция → EF-адаптер | Чисто |
|
||||
| T2 → T6/T7 | IKanjStore → сервисы | Чисто |
|
||||
| T3 → T7 | ColumnRules → move/restore matchHits | Чисто |
|
||||
| T5 → T7 | PushAsync → логирование обучения при move/trash/restore | Чисто |
|
||||
| T7 → T8/T10 | CardsService/StorageTick → эндпоинты/tick | Чисто |
|
||||
| T6/T7 → T8 | BoardsService/CardsService → эндпоинты | Чисто |
|
||||
| T9 → T8 | SSE-брокер публикует new_lead/toast из эндпоинтов | Чисто (только эндпоинты публикуют) |
|
||||
| T10/T11 → T8 | StorageTickService + Scheduler | Чисто |
|
||||
| T12 | Kanban→Settings: IRatesChangedListener в Settings, реализация в Kanban | Kanban зависит от Settings (разрешено); циклов нет |
|
||||
| T13/T14 | demo/suggest зависят от CardsService/IKanjStore | Чисто |
|
||||
| T12→T1 | Conv* колонки в Cards — в миграции T1 | Чисто |
|
||||
| T5 | IMlClient контракт меняется (PushAsync) — Contracts/Integrations | Проверить обратную совместимость (этап 2 LocalMlClient) |
|
||||
|
||||
## Task status
|
||||
@@ -0,0 +1,70 @@
|
||||
# Task 1 — «Миграция TenantKanban: Boards/Cards/LeadComments/CardMoves/MlOutbox» — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты PASS, миграция применена к dev-схеме дефолтного тенанта, psql-приёмка зелёная).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — сущности (`src/core/Deal.Infrastructure/Persistence/Entities/`, 1 тип = 1 файл)
|
||||
|
||||
| Файл | Таблица | Ключевые поля |
|
||||
|---|---|---|
|
||||
| `BoardEntity.cs` | `Boards` | Id (text PK), Name, Description, Color, Width, Position (int), KeywordsJson (text), Prompt, VisibleFieldsJson (text), Collapsed (bool), Suggested (bool), RulesJson (text), Note, CreatedAt (timestamptz) |
|
||||
| `CardEntity.cs` | `Cards` | Id (text PK), Col (text, max 200), IsNew, IsVacancy, IsVacancyKnown (bool), Title, Summary, StackJson (text), BudgetFrom/To (double?), BudgetCur, ConvFrom/To (double?), ConvCur, Contact, ContactsJson (text), ChannelName/Handle/Hue, ReceivedAt (timestamptz), SourceMsg (text), SourceDialogId, SourceMsgId (bigint?), PrevCol, ArchivedAt (timestamptz?), MatchHitsJson (text), CreatedAt (timestamptz) |
|
||||
| `LeadCommentEntity.cs` | `LeadComments` | Id (text PK, `cm_`), CardId (FK→Cards.Id, cascade), By, Text, CreatedAt |
|
||||
| `CardMoveEntity.cs` | `CardMoves` | Id (text PK, `lm_`), LeadId (text, без FK), Action, FromCol (text?), ToCol (text?), CreatedAt |
|
||||
| `MlOutboxEntity.cs` | `MlOutbox` | Id (text PK, `mle_`), Text, Label, Delta (double, 1.0), CreatedAt |
|
||||
|
||||
Все строковые NOT NULL поля в C# — `string` c дефолтным значением (как `TenantSettingEntity`); JSON-поля —
|
||||
`text` с сериализованным JSON (конвенция `value_json`). Времена — `DateTimeOffset` → `timestamptz`.
|
||||
|
||||
### Созданы — конфигурации (`src/core/Deal.Infrastructure/Persistence/`)
|
||||
|
||||
| Файл | Содержание |
|
||||
|---|---|
|
||||
| `BoardConfiguration.cs` | `ToTable("Boards")`, HasKey(Id), JSON-поля `.HasColumnType("text")`, индекс `IX_Boards_Suggested_Position` (Suggested, Position) |
|
||||
| `CardConfiguration.cs` | `ToTable("Cards")`, HasKey(Id), `Col.HasMaxLength(200)`, JSON-поля+SourceMsg `.HasColumnType("text")`, индексы `IX_Cards_Col_ReceivedAt` (Col ASC, ReceivedAt DESC) и `IX_Cards_Col_IsNew` (Col, IsNew) |
|
||||
| `LeadCommentConfiguration.cs` | `ToTable("LeadComments")`, HasKey(Id), FK `HasOne<CardEntity>().WithMany().HasForeignKey(CardId).OnDelete(Cascade)`, индекс `IX_LeadComments_CardId` |
|
||||
| `CardMoveConfiguration.cs` | `ToTable("CardMoves")`, HasKey(Id); без FK (журнал живёт дольше карточки) |
|
||||
| `MlOutboxConfiguration.cs` | `ToTable("MlOutbox")`, HasKey(Id), индекс `IX_MlOutbox_CreatedAt`; без FK |
|
||||
|
||||
### Изменены
|
||||
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — добавлены DbSet'ы `Boards/Cards/LeadComments/CardMoves/MlOutbox`
|
||||
и вызовы `ApplyConfiguration(...)` в `OnModelCreating` (паттерн `TenantSettingConfiguration` — без `ApplyConfigurationsFromAssembly`).
|
||||
- `src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs` (+ `.Designer.cs`, обновлён `TenantDbContextModelSnapshot.cs`) — миграция.
|
||||
|
||||
## Выбор типов чисел: `double?` (НЕ `decimal`)
|
||||
|
||||
Обоснование:
|
||||
1. Прототип хранит бюджет/конверсию/вес обучения в DuckDB как `DOUBLE` (`backend/app/db.py`: `budget_from DOUBLE`, …, `ml_outbox.delta DOUBLE NOT NULL DEFAULT 1.0`); Postgres `double precision` ≡ IEEE-754 double — 1:1 без потерь.
|
||||
2. Значения бюджета с суммой-строками прототипа («2к», «от 0 до 100») — дробные; конверсия (Ruling 7) — умножение на float-курс (`convert_amount`/`budget_to_target`, курсы CBR — float). Денежная арифметика с точностью не ведётся — сумма только отображается/нормализуется.
|
||||
3. `decimal` добавил бы лишний маппинг `numeric` и конверсии на границе с ML/курсами без выгоды (нет бухгалтерского округления).
|
||||
|
||||
Итого: `BudgetFrom/BudgetTo/ConvFrom/ConvTo` — `double?` (nullable, «суммы нет» = NULL); `MlOutbox.Delta` — `double` (NOT NULL, дефолт значения 1.0 задан в C#).
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
- **Имя сущности `MlOutboxEntity`/`MlOutboxConfiguration`** — по формулировке задания (план в Files называл `MlOutboxItemEntity`; Ruling 1 и таблица — `MlOutbox`). Влияния на DDL нет (таблица `MlOutbox`), но стоит свериться при Task 4/5.
|
||||
- **DB-дефолты колонок не заданы** (`HasDefaultValue` не использован): EF опускает колонку в INSERT, если значение = CLR-дефолт и настроен store-дефолт — `IsNew=false` (перенос из доски, `mark_seen`) уехало бы в DB-дефолт TRUE. Прототипные дефолты (`IsNew=true`, `Delta=1.0`, цвета/ширины) перенесены в C#-инициализаторы сущностей; приложение пишет полные строки.
|
||||
- Дефолт `LeadComments`: колонки Id/CardId/By/Text/CreatedAt — нормализация объекта комментария прототипа `{id, by, text, time}`; human-метка `time` вычисляется маппингом от `CreatedAt` (Ruling 10, как у карточек от `ReceivedAt`).
|
||||
- `CardMoves`/`MlOutbox` без FK — по Ruling 1; `Cards.Col` без FK, max 200; `LeadComments.CardId` FK cascade.
|
||||
|
||||
## Миграция и psql-проверка
|
||||
|
||||
- Создана: `dotnet ef migrations add TenantKanban --context TenantDbContext --output-dir Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (из `src/core`).
|
||||
- DDL: 5 `CreateTable` без схемы (search_path), PK на Id; `IX_Cards_Col_ReceivedAt` c `descending: [false, true]`; `FK_LeadComments_Cards_CardId` ON DELETE CASCADE.
|
||||
- Применение: краткий старт `Deal.Api` — `TenantBootstrapService`/провижинер применил миграцию к схеме дефолтного тенанта (реестр → все схемы).
|
||||
- `dotnet ef migrations list`: `InitialTenant (Pending)`, `TenantKanban (Pending)` (список читает историю из public — таблица в схеме тенанта, поэтому «Pending» не значим).
|
||||
|
||||
psql (`schema tenant_00000000000000000000000000000001`):
|
||||
- Таблицы: `Boards, CardMoves, Cards, LeadComments, MlOutbox` (+ `settings`, `__TenantMigrationsHistory`) — созданы.
|
||||
- PK: `PK_Boards/PK_Cards/PK_LeadComments/PK_CardMoves/PK_MlOutbox`.
|
||||
- Индексы: `IX_Cards_Col_ReceivedAt` = `("Col", "ReceivedAt" DESC)`, `IX_Cards_Col_IsNew`, `IX_Boards_Suggested_Position`, `IX_LeadComments_CardId`, `IX_MlOutbox_CreatedAt`.
|
||||
- `__TenantMigrationsHistory` содержит `20260905193010_InitialTenant` и `20260906125058_TenantKanban`.
|
||||
|
||||
## Валидация
|
||||
|
||||
- `dotnet build Deal.sln`: Предупреждений 0, Ошибок 0.
|
||||
- `dotnet test tests/Deal.Tests.Unit`: 175/175 PASS (MarkerTests PASS).
|
||||
- Диагностики: только pre-existing ошибки в прототипе `backend/` (вне зоны задачи).
|
||||
- Диагностический инструмент не показывал предупреждений в `src/core`.
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 10 curl-приёмка: POST /api/admin/tick + POST /api/admin/fts/rebuild на :5080
|
||||
# (план Task 10 L395-397; Ruling 6/8/11; dashboard_routes.py L261-264, L327-337; api-map §3.2 L105-107;
|
||||
# store.js tickAuto L1855-1863, rebuildFts L1884-1889). Сценарий: очистка kanban-таблиц дефолтного
|
||||
# тенанта → 401-гейты без куки (/admin/tick, /admin/fts/rebuild) → login admin/admin →
|
||||
# POST /admin/tick → форма {storage:{archived,purgedArchive,purgedTrash,purgedRejected: 0},
|
||||
# reminders:[], pipeline:{}, queue:0} (пусто — реальные перемещения карточек после Task 13) →
|
||||
# POST /admin/fts/rebuild → {ok:true, ready:true} (заглушка Ruling 6) → logout → 401 на tick.
|
||||
# Вывод всех шагов в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task10-jar.txt"
|
||||
OUT="/tmp/task10-out.txt"
|
||||
LOG="/tmp/task10-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка kanban-таблиц дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('autoArchive','archiveAfterDays','archiveClearDays','trashClearDays');"
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\");")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] kanban-таблицы пусты"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] kanban-таблицы не пусты (Boards+Cards = $ROWS_LEFT)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. 401-гейты без куки: /api/admin/tick, /api/admin/fts/rebuild =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "/api/admin/tick без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "/api/admin/fts/rebuild без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 3. POST /api/auth/login admin/admin =="
|
||||
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"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
|
||||
|
||||
echo
|
||||
echo "== 4. POST /api/admin/tick → storage-нули + форма {reminders, pipeline, queue} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "admin/tick 200" '[HTTP:200]'
|
||||
check "storage-форма нулей (карточек нет)" \
|
||||
'"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0}'
|
||||
check "reminders:[] (этап 5)" '"reminders":[]'
|
||||
check "pipeline:{} (этап 4)" '"pipeline":{}'
|
||||
check "queue:0 (этап 4)" '"queue":0'
|
||||
|
||||
echo
|
||||
echo "== 5. POST /api/admin/fts/rebuild → {ok:true, ready:true} (заглушка Ruling 6) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "admin/fts/rebuild 200 {ok,ready}" '[HTTP:200]' '{"ok":true,"ready":true}'
|
||||
|
||||
echo
|
||||
echo "== 6. POST /api/auth/logout, затем POST /api/admin/tick — 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "после logout /api/admin/tick 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
|
||||
|
||||
echo
|
||||
echo "== 7. Очистка kanban-таблиц после приёмки (dev-БД к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('autoArchive','archiveAfterDays','archiveClearDays','trashClearDays');"
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки curl-приёмки прошли"
|
||||
@@ -0,0 +1,77 @@
|
||||
# Task 10 — «StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast» — отчёт
|
||||
|
||||
Статус: **complete** (build 0/0, тесты 342/342 PASS — +10 новых, curl-приёмка :5080 PASS=12 FAIL=0).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 10 (L380–398), Rulings 5/6/8/11;
|
||||
эталоны — `backend/app/services/leads.py` tick_storage L454–493 + notify_tick_stats L496–504,
|
||||
`backend/app/routers/dashboard_routes.py` L261–264 (fts_rebuild) и L327–337 (admin_tick),
|
||||
`src/frontend/src/store.js` tickAuto L1855–1863 / rebuildFts L1884–1889, api-map §3.2 L103–112.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `src/core/Deal.Modules.Kanban/Application/`
|
||||
- `StorageTickService.cs` — чистый сервис модуля (без EF/HTTP/SSE): `TickAsync(CancellationToken)` повторяет
|
||||
`tick_storage` (Ruling 8). Читает autoArchive/archiveAfterDays/archiveClearDays/trashClearDays через
|
||||
`ISettingsStore` (отсутствие/повреждённый JSON → дефолты `SettingsDefaults` — мягкая семантика как
|
||||
LocalMlClient); один `DateTimeOffset.UtcNow` на весь тик. Порядок 1:1: (1) автоархив кандидатов досок+inbox
|
||||
(`ListArchiveCandidatesAsync(now − afterDays)`) → `UpdateColumnAsync` col=archive, isNew=false,
|
||||
PrevCol=null (не трогаем, Ruling 8), ArchivedAt=now, matchHits=[]; (2) очистка архива
|
||||
(`ListExpiredArchiveCandidatesAsync(now − archiveClearDays)`); (3) очистка корзины
|
||||
(`ListTrashCandidatesAsync(now − trashClearDays)`); удаление пачкой `PurgeAsync`. Возврат
|
||||
`StorageTickStatsDto` (Archived/PurgedArchive/PurgedTrash/PurgedRejected: 0 — отсев этап 4). SSE НЕ публикует.
|
||||
|
||||
### Создан — `src/core/Deal.Api/Endpoints/`
|
||||
- `StorageEndpoints.cs` (`MapStorageEndpoints`) — POST `/api/admin/tick`: 401-гейт → `StorageTickService.TickAsync`
|
||||
→ публикация SSE-тостов по ненулевым счётчикам (тексты/иконки 1:1 notify_tick_stats: «Автоархив: N
|
||||
карточек»/clock, «Архив очищен: N (90 дн.)»/trash, «Корзина очищена: N (7 дн.)»/trash; дни в скобках —
|
||||
фиксированные строки прототипа; канал тенанта сессии, без подписчиков — no-op) → ответ
|
||||
`{storage, reminders: [], pipeline: {}, queue: 0}` (reminders/pipeline/queue — заглушки этапов 5/4; фронт
|
||||
tickAuto читает только storage). POST `/api/admin/fts/rebuild` → `{ok:true, ready:true}` (заглушка Ruling 6).
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs` — `AddScoped<StorageTickService>()`.
|
||||
- `src/core/Deal.Api/Program.cs` — `app.MapStorageEndpoints();` после MapLeadsEndpoints (роутер dashboard).
|
||||
- `src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs` — реализованы методы правил хранения порта (были
|
||||
NotSupportedException): кандидаты автоархива/архива/корзины, `PurgeAsync`; модель `archived_at` рядом с
|
||||
CardDto (`archivedAtById`: UpdateColumnAsync пишет/обнуляет, purge/delete/clear-col чистят; ReceivedAt — из
|
||||
`ReceivedAtMs` epoch-ms как маппинг адаптера). Хелперы `SetArchivedAt`/`ArchivedAtOf` для тестов.
|
||||
- Создан: `tests/Deal.Tests.Unit/StorageTickServiceTests.cs` (10 тестов).
|
||||
- `.superpowers/sdd/deal-stage3-kanban/task-10-curl-acceptance.sh` (+ `.log`-запись вывода шага) и
|
||||
`progress.md` (Task 10 complete).
|
||||
|
||||
## Решения (зафиксированные)
|
||||
1. **Имя метода — `TickAsync`** (план Task 10 L386 и Task 11 L405; вариант «ExecuteTickAsync» в описании
|
||||
задачи не использован — план для Tasks 10/11 един на имя).
|
||||
2. **Удаление очисток — `PurgeAsync`** (пачка, порт документирован «для очисток тика», Ruling 8), а не
|
||||
построчный DeleteForever из формулировки плана L383 (порт T2 уже дал пачечный метод).
|
||||
3. **Счётчики очисток = фактически удалённое** (возврат `PurgeAsync`); автоархив — по числу кандидатов
|
||||
(как python инкремент на строку).
|
||||
4. **Тосты с «(90 дн.)»/«(7 дн.)» — фиксированные строки прототипа** (notify_tick_stats L500/L502), не
|
||||
пересчитываются от фактических настроек; toast purgedRejected не публикуется (всегда 0, этап 4).
|
||||
5. **Публикации SSE — только из эндпоинта** (Ruling 5): сервис модуля чистый; `PublishTickToasts` —
|
||||
приватный хелпер StorageEndpoints, тексты/иконки — константы.
|
||||
6. **Чтение настроек** — мягкий дефолт на отсутствие/битый JSON (как LocalMlClient.ReadMlEnabledAsync);
|
||||
границы дней без повторного клампа (кламп 1..30 — ответственность SettingsService на PATCH).
|
||||
7. **FakeKanjStore.ArchivedAt** — CardDto не несёт ArchivedAt (Ruling 10), поэтому метка архивации
|
||||
моделируется отдельным словарём; кандидатные методы повторяют 1:1 SQL KanbanStore (колонка/граница <).
|
||||
|
||||
## Проверка
|
||||
1. **Build**: `dotnet build Deal.sln` (из `src/core`) — 0 ошибок / 0 предупреждений.
|
||||
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — **342/342 PASS** (было 332; +10 StorageTickServiceTests:
|
||||
дефолтный автоархив (доски+inbox, is_new=false, archived_at≈now, matchHits пусто, молодые не тронуты),
|
||||
autoArchive=false, archiveAfterDays 30/1 из настроек, повторный тик без re-архива/очистки, очистка архива
|
||||
по ArchivedAt (дефолт 90 и из настроек 30), очистка корзины по ReceivedAt (дефолт 7 и из настроек 3),
|
||||
пустой тик → нули + purgedRejected:0).
|
||||
3. **Curl-приёмка** (:5080, admin/admin, kanban-таблицы очищены; `task-10-curl-acceptance.log`): PASS=12
|
||||
FAIL=0 — 401 без куки на tick/fts-rebuild; login; POST /admin/tick →
|
||||
`{"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0},"reminders":[],"pipeline":{},"queue":0}`
|
||||
(форма нулей — карточек нет); fts/rebuild → `{"ok":true,"ready":true}`; logout → 401 на tick.
|
||||
4. Диагностики по изменённым C#-файлам — без ошибок/предупреждений (refresh показывает только pre-existing
|
||||
lint прототипа `backend/*.py`, вне scope этапа).
|
||||
|
||||
## Concerns для Task 11+
|
||||
- Реальные перемещения тика (archived=1/purge-тосты) curl-приёмкой не проверяемы до Task 13 (демо-карточки
|
||||
и age-lead) — финальная сквозная приёмка в T15 (план L395–397); юнит-покрытие перемещений — на фейке выше.
|
||||
- Task 11 (StorageTickScheduler) будет звать `StorageTickService.TickAsync` в собственном scope с
|
||||
ITenantContext и публиковать SSE-тосты в канал тенанта (Ruling 8) — метод/контракт готовы.
|
||||
- «90 дн.»/«7 дн.» в тостах фиксированы как в прототипе даже при изменённых archiveClearDays/trashClearDays —
|
||||
осознанное 1:1-расхождение (зафиксировано в п.4).
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 11 curl-приёмка: фоновый StorageTickScheduler на :5080 (план Task 11 L413-414; Ruling 8;
|
||||
# main.py _storage_loop L43-53). Сценарий: кладём в БД дефолтного тенанта просроченную карточку inbox
|
||||
# (received_at старше дефолтных archiveAfterDays=14) → запуск Deal.Api → фоновый цикл (первый проход —
|
||||
# сразу после старта, далее каждые 30 с) архивирует её БЕЗ ручного POST /admin/tick → повторный тик
|
||||
# руками (login/tick) работает как в Task 10 (storage-нули — карточка уже в archive, свежая) → после
|
||||
# ~35 с ожидания (второй проход цикла) в логе нет ошибок цикла → карточка остаётся в archive
|
||||
# (до очистки архива 90 дн. не дошла). Вывод всех шагов в stdout.
|
||||
# Очистка после приёмки: удаляем демо-карточку (таблицы канбана dev-БД к исходному состоянию).
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task11-jar.txt"
|
||||
OUT="/tmp/task11-out.txt"
|
||||
LOG="/tmp/task11-api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
CARD_ID="l_t11_sched"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
check() {
|
||||
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$OUT"; then
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — не найдено: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 2
|
||||
echo "== Очистка демо-карточки $CARD_ID (dev-БД к исходному состоянию) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$CARD_ID';" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';"
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
echo "== 0. Очистка прежней демо-карточки (повторяемость) и вставка просроченной карточки inbox =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
|
||||
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
|
||||
\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",\"ChannelHandle\",\"ChannelHue\",
|
||||
\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",\"MatchHitsJson\",\"CreatedAt\")
|
||||
VALUES ('$CARD_ID','inbox',true,false,false,'','','[]','','','','[]','','','',
|
||||
now() - interval '20 days','','','','[]', now());"
|
||||
COL_BEFORE=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
|
||||
if [ "$COL_BEFORE" = "inbox" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] демо-карточка вставлена в inbox (received_at −20 дн. > дефолт 14)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] демо-карточка не вставлена (col=$COL_BEFORE)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
i=0
|
||||
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. Фоновый цикл: первый проход сразу после старта архивирует карточку БЕЗ ручного tick =="
|
||||
sleep 5
|
||||
COL_AFTER=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
|
||||
ARCHIVED_AT=$($PSQL_BASE -t -A -c "SELECT \"ArchivedAt\" IS NOT NULL FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
|
||||
if [ "$COL_AFTER" = "archive" ] && [ "$ARCHIVED_AT" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] автоархив фоновым циклом: col=$COL_AFTER, archived_at выставлен"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] автоархив не сработал: col=$COL_AFTER, archived_at set=$ARCHIVED_AT"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 3. Ручной POST /api/admin/tick работает как в Task 10 (карточка уже в archive — storage-нули) =="
|
||||
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"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
cat "$OUT"
|
||||
echo
|
||||
check "admin/tick 200" '[HTTP:200]'
|
||||
check "storage-форма нулей (карточка архивирована первым проходом цикла)" \
|
||||
'"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0}'
|
||||
check "reminders:[] (этап 5)" '"reminders":[]'
|
||||
check "pipeline:{} (этап 4)" '"pipeline":{}'
|
||||
check "queue:0 (этап 4)" '"queue":0'
|
||||
|
||||
echo
|
||||
echo "== 4. Второй проход цикла (~35 с ожидания): карточка остаётся в archive, лог без ошибок =="
|
||||
sleep 35
|
||||
COL_LATER=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
|
||||
if [ "$COL_LATER" = "archive" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] после ~35 с карточка всё ещё в archive (очистка архива — 90 дн.)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] карточка изменила колонку: col=$COL_LATER"
|
||||
fi
|
||||
|
||||
LOOP_ERRORS=$(grep -c "не удалось\|Unhandled exception\|System\..*Exception" "$LOG")
|
||||
if [ "$LOOP_ERRORS" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] в логе нет ошибок цикла правил хранения"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] в логе ошибки цикла ($LOOP_ERRORS):"
|
||||
grep "не удалось\|Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 5. Остановка: kill Deal.Api =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
sleep 3
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть проваленные проверки"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] все проверки Task 11 прошли"
|
||||
@@ -0,0 +1,84 @@
|
||||
# Task 11 — «StorageTickScheduler — фоновый цикл правил хранения по тенантам» — отчёт
|
||||
|
||||
Статус: **complete** (build 0/0; тесты 348/348 PASS — +6 новых; curl-приёмка :5080 PASS=10 FAIL=0 —
|
||||
автоархив фоновым циклом без ручного tick, лог без ошибок, T10-тик не сломан).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 11 (L400–414), Ruling 8 (L134–145);
|
||||
эталоны — `backend/app/main.py` _storage_loop L43–53 (тик до первого sleep + каждые 30 с + публикация
|
||||
тостов notify_tick_stats из фонового цикла), `A/Hosting/TenantBootstrapService.cs` (IHostedService +
|
||||
scope'ы), `A/RatesRefreshScheduler.cs` (in-flight guard + try/catch), `A/Endpoints/StorageEndpoints.cs`
|
||||
(публикация тостов T10, вынесена в общий хелпер).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `src/core/Deal.Api/Hosting/StorageTickScheduler.cs` (IHostedService, namespace Deal.Api.Hosting)
|
||||
- `System.Threading.Timer` период 30 с (`TickPeriodSeconds`); первый проход — сразу после старта
|
||||
(dueTime=0), как в прототипе (тик до первого `asyncio.sleep(30)`). Каждый проход — собственный scope:
|
||||
список тенантов системного реестра (`ITenantRepository`, вне tenant-контекста — паттерн
|
||||
TenantBootstrapService); на каждый тенант — вложенный scope: `ITenantContext.SetTenant(new TenantId(id
|
||||
"N"))` (эталон SessionMiddleware) → resolve `StorageTickService` ПОСЛЕ SetTenant (TenantDbContext строится
|
||||
от схемы) → `TickAsync(ct)` → `StorageToastPublisher.PublishTickToasts(tenant.Id, stats)` (канал тенанта,
|
||||
без подписчиков — no-op); `Reset()` в finally.
|
||||
- In-flight guard `Interlocked.CompareExchange` (как RatesRefreshScheduler): проход длиннее 30 с — тик
|
||||
таймера пропускается. Ошибки: тик одного тенанта → LogWarning, остальные тенанты обрабатываются
|
||||
(main.py ловит на весь цикл — для мультитенанта изоляция по тенанту); сбой реестра → LogError, цикл
|
||||
живёт. StopAsync: Change(∞)/Dispose таймера → `shutdownCts.Cancel()` (EF-запросы тика наблюдают токен) →
|
||||
ожидание текущего прохода не дольше лимита хоста (`WaitAsync(ct)`) — graceful.
|
||||
- `RunCycleAsync(CancellationToken)` публичен: та же guarded-точка, что у таймера — unit-тесты зовут
|
||||
итерацию напрямую (тайминги цикла не тестируются, как и задумано планом).
|
||||
|
||||
### Создан — `src/core/Deal.Api/Events/StorageToastPublisher.cs` (Task 2 задания: вынос общей публикации)
|
||||
- Хелпер Api-слоя `PublishTickToasts(Guid tenantId, StorageTickStatsDto stats)` — переезд приватного
|
||||
`StorageEndpoints.PublishTickToasts` и его констант (тексты/иконки 1:1 notify_tick_stats L496–504:
|
||||
«Автоархив: N карточек»/clock, «Архив очищен: N (90 дн.)»/trash, «Корзина очищена: N (7 дн.)»/trash;
|
||||
purgedRejected не публикуется — этап 4). Единственный источник тостов для ручного тика (T10) и
|
||||
фонового цикла (T11) — без дублирования; публикация в канал тенанта, без подписчиков — no-op (Ruling 5).
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Api/Endpoints/StorageEndpoints.cs` — `AdminTickAsync` резолвит
|
||||
`StorageToastPublisher` из RequestServices и зовёт `PublishTickToasts` (контракт ответа и поведение
|
||||
T10 не изменились: тосты по тем же текстам/иконкам/счётчикам).
|
||||
- `src/core/Deal.Api/Program.cs` — `AddSingleton<StorageToastPublisher>()` (рядом с SseBroker) и
|
||||
`AddHostedService<StorageTickScheduler>()` ПОСЛЕ `TenantBootstrapService` (первый проход стартует после
|
||||
провижининга схем).
|
||||
- Создан тест-фейк: `tests/Deal.Tests.Unit/FakeTenantRepository.cs` (реестр с фиксированным списком;
|
||||
FindById/Create — NotSupportedException, как FakeKanjStore).
|
||||
- Созданы тесты: `tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs` (4 теста) и
|
||||
`tests/Deal.Tests.Unit/StorageToastPublisherTests.cs` (2 теста).
|
||||
- `.superpowers/sdd/deal-stage3-kanban/task-11-curl-acceptance.sh` (+ `.log`) и `progress.md` (Task 11 complete).
|
||||
|
||||
## Решения (зафиксированные)
|
||||
1. **Первый проход — сразу при старте** (dueTime=0): 1:1 с прототипом, где тик выполняется до первого
|
||||
sleep (main.py L47). Регистрация после Bootstrap гарантирует готовность схем.
|
||||
2. **Таймер + guard Interlocked**, а не BackgroundService/PeriodicTimer: фиксированный период 30 с с
|
||||
пропуском «длинного» прохода — буквально «Timer 30 с + in-flight guard, как RatesRefreshScheduler».
|
||||
3. **Отмена graceful**: собственный `shutdownCts` (Cancel в StopAsync) → тик тенанта прерывается по
|
||||
токену (OCE в тике ретраится наверх и гасится на уровне прохода без лога — штатная остановка);
|
||||
StopAsync ждёт текущий проход в пределах лимита хоста.
|
||||
4. **Изоляция ошибок по тенанту**: падение одного тенанта (имитация сбоя схемы/БД) не валит проход —
|
||||
warning в лог, остальные тенанты тикаются (в main.py catch на весь цикл — для мультитенанта
|
||||
осознанно строже, Ruling 8 «обход ВСЕХ тенантов»).
|
||||
5. **StorageToastPublisher — singleton Api-слоя**: переиспользуется эндпоинтом и планировщиком; модуль
|
||||
Kanban остался чистым (Ruling 5); тексты/иконки — в одном месте.
|
||||
|
||||
## Проверка
|
||||
1. **Build**: `dotnet build Deal.sln` (из `src/core`) — 0 ошибок / 0 предупреждений.
|
||||
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — **348/348 PASS** (было 342; +4 StorageTickSchedulerTests
|
||||
на реальном DI+fake: обход всех тенантов в собственных scope (карточка каждого ушла в archive, тост в
|
||||
свой канал, TenantContext сброшен), нулевые счётчики — тост только тенанту с изменениями, падение тика
|
||||
одного тенанта не валит остальных, падение реестра не выбрасывается наружу; +2 StorageToastPublisherTests:
|
||||
тексты/иконки 1:1 прототипа по ненулевым счётчикам (purgedRejected=4 — без тоста «Отсев очищен»),
|
||||
нули — без публикаций).
|
||||
3. **Curl-приёмка** (:5080, admin/admin; `task-11-curl-acceptance.log`): PASS=10 FAIL=0 — вставка
|
||||
просроченной карточки inbox (received_at −20 дн.) через psql в схему дефолтного тенанта → запуск Api →
|
||||
первый проход цикла архивировал карточку БЕЗ ручного tick (col=archive, archived_at set) → ручной
|
||||
POST /admin/tick — форма T10 как была (storage-нули — карточка уже в archive) → второй проход через
|
||||
~35 с: карточка остаётся в archive (до очистки 90 дн. не дошла), в логе НЕТ ошибок цикла → kill —
|
||||
чистая остановка, демо-карточка удалена (dev-БД к исходному состоянию).
|
||||
4. Диагностики по изменённым C#-файлам — без ошибок/предупреждений.
|
||||
|
||||
## Concerns для Task 12+
|
||||
- Сквозной сценарий с SSE-подписчиком (тост фонового цикла на открытом /api/events) и демо-путём
|
||||
(Task 13 demo/age-lead) — финальная приёмка T15 (план L413–414). Юнит-покрытие публикаций — на
|
||||
каналах SseBroker с реальными текстами.
|
||||
- Тайминги цикла (30-секундный период, stop-ожидание) unit-тестами не покрыты (план: «юнит ограничен»);
|
||||
старт/остановка проверены в curl-приёмке и логе.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user