ci / build-test (push) Canceled after 0s
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 зелёные.
229 lines
24 KiB
Markdown
229 lines
24 KiB
Markdown
# Ревью качества кода «Дейл» (2026-09-08)
|
||
|
||
> Исторический документ этапа 8 (ревью, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||
|
||
Многоосевое ревью (корректность/читаемость/архитектура/безопасность/производительность) бэкенда и
|
||
фронтенда. Проводилось 5 ревьюерами по непересекающимся зонам (чтение; правок не вносилось), ключевые
|
||
находки перепроверены по коду. Проект НЕ git. Метки: **[Critical]/[Required]/[Nit]/[Optional]**
|
||
(Required = исправить до прода; Nit = желательно; Optional = задел).
|
||
|
||
## Сводка
|
||
|
||
| Зона | Объём | Critical | Required | Nit | Optional |
|
||
|---|---|---|---|---|---|
|
||
| Frontend (Vue3, JS) | 25 файлов / 11.3k LOC | 0 | 6 | 6 | 1 |
|
||
| Core-каркас (Api/Infrastructure/Contracts) | ~370 файлов | 0 | 9 | 7 | 5 |
|
||
| Модули Kanban/Pipeline/Projects | ~140 файлов | 0 | 10 | 5 | 2 |
|
||
| Модули Settings/Telegram/Tenants/Discovery | ~130 файлов | 0 | 7 | 8 | 3 |
|
||
| gRPC-сервисы (telegram/ai/ml) + proto | ~135 файлов | 0 | 8 | 8 | 4 |
|
||
| **Итого** | **~1100 файлов** | **0** | **40** | **34** | **15** |
|
||
|
||
Общий вердикт: **код высокого качества** — чистая port&adapter-архитектура, 1 тип=1 файл, тенант-
|
||
изоляция через схему на тенанта спроектирована сильно, SQL параметризован, XSS/секреты на фронте и в
|
||
сервисах чистые. Найдено 0 критических дыр класса «ключ наружу/доступ к чужому тенанту». Ниже — что
|
||
требует исправления и что стоит улучшить. Подробности по зонам — в рабочем журнале сессии (5 отчётов
|
||
субагентов с file:line); здесь — консолидированный список.
|
||
|
||
---
|
||
|
||
## A. Безопасность (приоритет 1)
|
||
|
||
1. **[Required] SSRF через baseUrl ИИ-провайдера.** `Deal.Infrastructure/Integrations/AiConnectionChecker.cs`
|
||
(проверка `ok:false/true`) + PATCH настроек разрешает тенанту задать произвольный `baseUrl` (в т.ч.
|
||
`http://127.0.0.1:...` — подтверждено acceptance-логом task-6). На не-local провайдере ключ API уходит
|
||
на указанный адрес → аутентифицированный тенант мультитенантного SaaS получает blind-сканер внутренней
|
||
сети/метаданных. Исправить: резолв DNS + запрет private/link-local/loopback при проверке и вызове
|
||
(или egress-фильтр); не принимать переопределение хоста для каталоговых провайдеров.
|
||
2. **[Required] Rate-limit и анти-брутфорс выключены по умолчанию.** `Deal.Api/Program.cs` (регистрация
|
||
лимитера), `RateLimitOptions` дефолт `Enabled=false` → без env в проде нет ни лимитов, ни
|
||
`LoginAttemptGuard`. compose.prod форсирует `true`, но дефолт кода опасен при запуске вне compose.
|
||
Исправить: стартовая проверка «Production ⇒ RateLimit:Enabled задан явно» (fail-closed).
|
||
3. **[Required] CORS fail-open при пустом allowlist.** `Program.cs` (AddCors): пустой
|
||
`Security:AllowedOrigins` = любой origin + `AllowCredentials` (задумано для dev). Исправить: в Production
|
||
пустой список = отказ на старте; «any origin» только в Development.
|
||
4. **[Required] Код инвайта пишется в audit_log сырым.** `JoinEndpoint.cs` — capability-токен в вечном
|
||
аудите операторов. Исправить: не логировать код (или его SHA-256).
|
||
5. **[Required] Пароль: минимум 4 символа.** `AuthEndpoints.cs`, `JoinEndpoint.cs`. Для публичного SaaS —
|
||
минимум 8–10 + проверка на границе; единая константа.
|
||
6. **[Required] Политика «ключ не перезаписывается маской» не реализована.** `SettingsService.cs`
|
||
(aiConfigs и tgKeys): PATCH со значением-маской (например `sk-1…90ab`, ≥8 симв., без `enc:`) зашифрует
|
||
маску и безвозвратно потеряет ключ. Комментарий «пустой/маска → не меняется» не подкреплён кодом.
|
||
Исправить: не шифровать значение, содержащее `…` (U+2026) либо пустое; тест на roundtrip.
|
||
7. **[Required] DDL прикладной ролью на старте и из tenant-ручки.** `TenantProvisioningService.cs`,
|
||
`FtsMaintenance.cs` — `CREATE SCHEMA/Migrate/INDEX` на каждом старте и `/api/admin/fts/rebuild`.
|
||
В проде это нарушение least privilege. Исправить: отдельные креды мигратора и runtime; fts-rebuild —
|
||
операторской ручкой.
|
||
8. **[Required] TenantId без инварианта формата.** `Deal.SharedKernel/Tenants/TenantId.cs` — значение идёт
|
||
в Search Path строки подключения и в DDL; `new TenantId(внешняя_строка)` = connection-string-инъекция.
|
||
Сейчас все потоки дают Guid, но тип не защищён. Исправить: конструктор от Guid / валидация 32 hex.
|
||
9. **[Required] gRPC-сервисы: нет серверных лимитов на входные данные.** AiServiceImpl, MlServiceImpl,
|
||
TelegramServiceImpl — контракты фиксируют лимиты («ядро обрежет»), но сервис их не enforcement:
|
||
платные LLM-вызовы на мегабайтных промптах, гигантские SQLite-транзакции. Исправить:
|
||
INVALID_ARGUMENT на границе + MaxReceiveMessageSize.
|
||
10. **[Required] mTLS по умолчанию выключен — тихая деградация до plaintext.** `MtlsOptions.cs` —
|
||
отсутствие/опечатка env молча даёт plaintext+только service-token. Исправить: fail-closed для
|
||
Production (или warn-on-startup) как для session-ключа.
|
||
11. **[Required] Инвайт: не проверяется существование/статус тенанта.** `JoinService.cs` — активация по
|
||
«битому» инвайту даёт FK-500 или пользователя на несуществующем тенанте.
|
||
12. **[Required] AddUsageAsync не атомарно.** `ITenantLimitStore.cs` — read-modify-write теряет списания
|
||
при параллельных ИИ-вызовах. Исправить: `UPDATE ... SET Used=Used+@n`.
|
||
13. **[Required] Echo-маска: секрет ≤8 символов отдаётся как есть.** `SettingsService.Mask` — маскировать
|
||
всегда (кроме пустого).
|
||
|
||
## B. Корректность / потеря данных (приоритет 2)
|
||
|
||
14. **[Required] Потеря данных при параллельных мутациях JSON-массивов проектной карточки.**
|
||
`ProjectsService.cs` (add_comment/add_link/remove_link), `ProjectFilesService.cs`: комментарии/ссылки/
|
||
файлы дописываются «read → PATCH полной заменой массива» без версии/транзакции; double-click теряет
|
||
запись. Исправить: append одним SQL (`jsonb ||`/`array_append`) или optimistic concurrency по `updated_at`.
|
||
15. **[Required] Коллизия objectKey файла.** `ProjectFilesService.cs` — «проект/карточка/мс_имя»: две
|
||
загрузки в одну мс = перезапись объекта. Исправить: случайный суффикс / id записи в ключе.
|
||
16. **[Required] Дедуп-pump не атомарен.** `PipelineWorkerService.cs` — Exists→Claim→create без проверки
|
||
результата claim — два конкурентных прохода создадут две карточки. Исправить: повторный Exists/
|
||
проверка результата Claim перед созданием.
|
||
17. **[Required] Move из trash/archive на доску минует снятие спам-сигнала.** `CardsService.cs` —
|
||
валидируется только цель; «spam +1» не снимается (unlearn только в restore). Исправить: запрет исхода
|
||
из archive/trash/taken в MoveLeadAsync (или симметричный unlearn).
|
||
18. **[Required] Параллельные пустые `catch { }` в модулях Telegram/Discovery** — сбои зеркала/превью/
|
||
backfill невидимы (ILogger в модулях не используется). Исправить: логировать.
|
||
19. **[Required] ChangePassword (фронт) шлёт захардкоженный oldPassword='admin'.** `store.js`,
|
||
`SettingsView.vue` — после смены пароля повторная смена невозможна, и пароль живёт в реактивном state.
|
||
Исправить: поле «текущий пароль», не хранить пароль в store.
|
||
20. **[Required] boot() роняет всё приложение одним сбоем** (фронт). `store.js`: параллельные get без
|
||
.catch — падение /api/rates (например) = toast «Сервер недоступен» + разлогин. Исправить:
|
||
необязательные секции в индивидуальные .catch; разлогин только при 401.
|
||
21. **[Required] applySettings затирает несохранённые промпты** (фронт). `store.js` — автосейв тумблера
|
||
применяет полный ответ и перезаписывает textarea промптов. Исправить: применять только запатченные ключи.
|
||
22. **[Required] Гонки устаревших ответов поиска** (фронт). `store.js` — старый ответ может перетереть
|
||
свежий/очищенный. Исправить: seq-токен/AbortController.
|
||
23. **[Required] DeleteExpiredSessionsAsync на каждое разрешение сессии.** `AuthService.cs`,
|
||
`OperatorAuthService.cs` — глобальный DELETE по public-таблицам в hot-path каждого запроса.
|
||
Исправить: фоновый цикл или «с вероятностью N%»/логин.
|
||
24. **[Required] ServiceTokenInterceptor проверяет токен только для unary RPC** — первый же
|
||
server-streaming RPC пройдёт без проверки; то же в access-логе. Исправить: все 4 handler'а.
|
||
25. **[Required] gRPC-логгер не логирует «прочие» исключения** (только OCE/RpcException) — 500-эквивалент
|
||
уходит мимо лога. Исправить: catch (Exception) → log + RpcException.
|
||
26. **[Required] Heartbeat/reconnect без таймаута** — зависший ConnectAsync последовательно блокирует
|
||
все тенанты и shutdown. Исправить: CancelAfter на попытку.
|
||
27. **[Required] QR: отмена RPC до первого URL не отменяет фоновую задачу** — «скрытая» авторизация.
|
||
Исправить: отменять саму задачу при отмене ожидания.
|
||
28. **[Required] TelegramBackfill fire-and-forget Task.Run из tenant-запроса без in-flight guard**
|
||
(параллельные полные перечитывания); фоновые задачи не отслеживаются хостом. Исправить: гейт операции
|
||
+ токен остановки хоста.
|
||
29. **[Required] int.Parse(apiId)** из пользовательской KV-настройки `TelegramEndpoints.cs` —
|
||
FormatException маскируется под 400 «не подключён». Исправить: TryParse + понятная ошибка.
|
||
|
||
## C. Архитектура / дублирование (приоритет 3)
|
||
|
||
30. **[Required]** 9 независимых реализаций чтения настроек (GetAsync+JsonDocument.Parse+дефолт) в
|
||
Settings/IncomingRules/RatesService/Discovery*/DialogsService — расхождение семантики уже видно.
|
||
**+** ~8 копий KV-хелперов (ReadBool/ReadInt/ReadString/ReadStringList) и 3 копии LoadRatesAsync в
|
||
Kanban/Pipeline/Projects. Исправить: один публичный снапшот настроек в Settings или SharedKernel +
|
||
общий RatesCacheReader.
|
||
31. **[Required]** Обвязка gRPC-сервисов (ServiceTokenInterceptor/RpcCallLogging/MtlsOptions/MtlsCertificates/
|
||
Logging + Host) скопирована в 3 независимых sln. Исправить: общий проект `Deal.Grpc.Hosting`.
|
||
32. **[Required]** Большие файлы: PipelineWorkerService (914), KanbanStore (726), DiscoveryStore (632),
|
||
ProjectsService (576), ProjectsEndpoints (568), CardsService (475), Program.cs (695), LocalFieldsParser
|
||
(438), GrpcTelegramClient (447), TelegramIngressService (409); фронт: SettingsView.vue (1779),
|
||
DiscoveryView.vue (1243), store.js (2434). Исправить: декомпозиция (см. ниже).
|
||
33. **[Required] Фронт: MoveMenu вешает document-слушатель на каждую карточку** (сотни карточек → сотни
|
||
слушателей). Исправить: один глобальный обработчик + id открытого меню в store.
|
||
34. **[Required] Фронт: квадратичные пересчёты колонок.** `store.js` — filter+sort на каждую колонку/
|
||
счётчик при каждом ре-рендере. Исправить: один computed Map<colId, sorted[]>.
|
||
35. **[Nit]** Дублирование доменных констант между модулями (EmptyCommentDetail/JustNowLabel/MlSpamLabel/
|
||
DefaultChannelHue/PlannedStage-литералы) и расхождение предиката «активные правила» (Kanban vs
|
||
AiClassifyContextBuilder) — вынести в единые реестры.
|
||
36. **[Nit]** Middleware сессий (Session vs OperatorSession) и токен-генераторы (SessionTokens/
|
||
InviteCodeGenerator/TenantAdminService) дублируются — обобщить.
|
||
37. **[Nit]** Легаси-ссылки на строки Python-прототипа в XML-doc (L177–191 и т.п.) — устаревают;
|
||
оставить «зачем/инвариант», убрать номера строк.
|
||
38. **[Nit]** Форматтеры времени и «знание» о контактах/типах файлов в 3–4 местах (фронт) — единый
|
||
модуль форматов и словари меток.
|
||
39. **[Nit]** `window.prompt` в renameBoard на фоне единого ConfirmDialog; дубликаты 86400000; ширины
|
||
колонок sm/md/lg в 3 местах — константы/единый RenameDialog.
|
||
|
||
## D. Мёртвый код (кандидаты на удаление)
|
||
|
||
- Фронт: `utils.js` fileTypeInfo/EXT_KINDS/KIND_LABELS (не импортируется); `store.js` — curName/fmtMoney
|
||
вне store, moveLead-мёртвая ветка, trashLead-пустой if, openDialog (не используется), checkReminders
|
||
(нигде не вызывается); опция «mock»-курсов — проверить, жив ли режим на бэкенде.
|
||
- Бэкенд: Kanban DemoLeadFactory недостижимый fallback PrimaryContact; DiscoverySearchErrorCounter —
|
||
singleton-счётчик без TTL/эвикции и с межтенантным ключом (переделать per-tenant или чистить).
|
||
|
||
## E. Что соответствует хорошим практикам (подтверждено)
|
||
|
||
- Тенант-изоляция сильная: схема на тенанта через Search Path, TenantDbContext запрещён вне tenant-запроса
|
||
(fail-fast), AsyncLocal сбрасывается в finally, gRPC-ингресс берёт tenant-id только из metadata, SSE
|
||
per-tenant.
|
||
- SQL параметризован везде (FromSqlInterpolated/ExecuteSqlInterpolated); массовые операции —
|
||
ExecuteUpdate/Delete; комментарии-батчи без N+1; AsNoTracking.
|
||
- Секреты не покидают систему: ключи шифруются (enc:+nonce‖ct‖tag), наружу маски; токены сессий — SHA-256
|
||
хэши; пароли Argon2id; куки httpOnly+SameSite=Lax; fail-closed service-token (с явным гардом
|
||
«пусто≠пусто»); path traversal защищён (SessionStore/ModelPool валидируют tenant-id как имя файла).
|
||
- Фронт: XSS-аудит чистый (v-html только через экранирующий renderSourceMessage со схемами http/tg),
|
||
токенов в localStorage нет (httpOnly-кука), все target=_blank с rel=noreferrer.
|
||
- Чистая архитектура port&adapter в модулях (нет EF/HTTP в Application), DTO-рекорды, DI-Registrar'ы,
|
||
направленные зависимости без циклов, константы-каталоги вместо магических строк.
|
||
|
||
## F. Рекомендуемый порядок исправлений
|
||
|
||
1. **Безопасность (A1–A13)** — до любого прода. Точечные правки + тесты.
|
||
2. **Потеря данных/корректность (B14–B29)** — гонки, дедуп, маски, boot/applySettings фронта.
|
||
3. **Архитектура (C30–C34)** — вынос общего grpc-hosting, снапшот настроек, декомпозиция больших файлов,
|
||
фронт: leadsByCol-компьютед и глобальный слушатель меню.
|
||
4. **Чистка мёртвого кода (D)** + реестры констант (C35–C39) — в рамках рефакторингов, не отдельно.
|
||
5. **Заделы (Optional)** — пагинация колонок, виртуализация списков, LRU для кэшей сессий WTelegram,
|
||
батчинг провижининга схем, MinIO tenant-префикс, per-request size-лимиты загрузок, single-flight
|
||
DiscoveryWorker.
|
||
|
||
---
|
||
|
||
## Статус исправлений (2026-09-08, после ревью)
|
||
|
||
Выполнено в ходе rework-захода (детали — `.superpowers/sdd/deal-stage8-quality-rework/progress.md` и
|
||
`docs/superpowers/STATUS.md`). Тесты: core **1135/1135**, telegram **118/118**, ai **52/52**, ml **38/38**,
|
||
фронт `npm run build` OK.
|
||
|
||
**A. Безопасность — закрыто (A1–A13):**
|
||
- A1 SSRF: `SettingsService` — baseUrl каталоговых облачных провайдеров не переопределяется (только
|
||
local/custom); `AiConnectionChecker` — запрет private/loopback/link-local адресов (в т.ч. 169.254.169.254).
|
||
- A2/A3: fail-closed в Production (RateLimit:Enabled обязателен, CORS-allowlist непустой, conn-string без
|
||
фолбэка) — стартовые проверки `Program.cs`.
|
||
- A4: код инвайта в аудите → SHA-256 `codeHash` (3 события, тесты обновлены).
|
||
- A5: пароль минимум 8 (единый `AuthService.MinNewPasswordLength`).
|
||
- A6: PATCH с маской ключа («…») больше не шифрует маску (терялся бы ключ); A13: короткие секреты
|
||
маскируются всегда (`MaskSecret`), apiId остаётся как есть (не секрет).
|
||
- A7: DDL (провижининг схем/миграции) — опциональная мигратор-строка `ConnectionStrings:DealMigrator`
|
||
(`ConnectionStringProvider.ForSchemaDdl`); dev/тесты — прежнее поведение.
|
||
- A8: `TenantId` — инвариант 32 hex (Guid N), фабрика FromGuid.
|
||
- A9: gRPC-сервисы — лимиты входных данных (INVALID_ARGUMENT) + MaxReceiveMessageSize=4MiB.
|
||
- A10: mTLS fail-closed в Production (сервисы).
|
||
- A11: `JoinService` — целевой тенант обязан существовать и быть активным до резервирования кода.
|
||
- A12: атомарный инкремент токенов (`UPDATE ... UsedTokens=UsedTokens+@n`) для Npgsql; EF-путь для InMemory.
|
||
- Доп.: int.TryParse apiId; ResolveSession учитывает статус пользователя; очистка протухших сессий — вне
|
||
hot-path.
|
||
|
||
**B. Корректность/потеря данных — закрыто (B14–B29):** атомарные append (comment/link/file) в ProjectStore
|
||
(1 SQL), objectKey файла с id записи, дедуп-pump атомарен (Claim→bool), move из trash/archive/taken запрещён,
|
||
пустые catch логируются (DiscLog/ILogger), фронт: смена пароля (oldPass), boot с .catch, applySettings не
|
||
затирает промпты, seq-токены поиска; интерцепторы gRPC на все 4 вида RPC, логгер catch(Exception), reconnect
|
||
с таймаутом, QR-cancel; backfill с in-flight guard + lifetime-токеном.
|
||
|
||
**C. Архитектура — закрыто:** C30 (единый `TenantSettingsSnapshot` вместо ~9 копий чтения настроек и 3 копий
|
||
`LoadRatesAsync`; удалён клон `RateTable.cs`), C31 (общий `src/grpc-hosting/Deal.Grpc.Hosting`; 15 файлов
|
||
дублей удалены), C32-декомпозиция (KanbanStore→5, PipelineWorkerService→8, DiscoveryStore→5, ProjectsService→4,
|
||
CardsService→3, SettingsService→6, DiscoveryWorkerService→6 partial; фронт: store.js→слайсы store/, вынесены
|
||
Telegram/Stop/Scope-вкладки SettingsView, DiscoveryCandidateCard), C33/C34 (MoveMenu, leadsByCol), C36
|
||
(`UrlSafeToken`). **Закрыто после ревью (2026-09-09):** C35 — общие реестры
|
||
`MlLearningLabels`/`SourceDefaults` в Deal.Contracts (метки обучения ML «spam»/«t:hire»/«t:order» и дефолтный
|
||
цвет источника «#666») вместо дублей MlSpamLabel/DefaultChannelHue/DefaultDialogHue/SpamLabel в
|
||
Pipeline/Discovery/Telegram/Infrastructure; единый предикат «активные правила» — AiClassifyContextBuilder
|
||
переведён на `ColumnRules.HasActiveRules` (Kanban; было расхождение Count>0 vs терм после trim); реестр
|
||
`ProjectStages` (9 id-констант вместо литералов) и общий `CardsService.JustNowLabel` (Projects/адаптер
|
||
KanbanStore); DiscoverySearchErrorCounter — TTL-эвикция (см. D). **Задел:** полный вынос остальных вкладок
|
||
SettingsView (риск без e2e).
|
||
|
||
**D. Мёртвый код:** удалён (фронт: fileTypeInfo/EXT_KINDS/curName/fmtMoney/openDialog/checkReminders и др.;
|
||
бэкенд: недостижимый PrimaryContact DemoLeadFactory и др.). DiscoverySearchErrorCounter — добавлена TTL-эвикция
|
||
записей (EntryTtlSeconds=1 ч, ленивая при Next/Reset, часы инъекцией; +4 теста) — задел D закрыт.
|