209 lines
21 KiB
Markdown
209 lines
21 KiB
Markdown
# Ревью TeleWave — план исправлений
|
||
|
||
Статус чекбоксов: `[ ]` не сделано · `[x]` исправлено · `[~]` в работе.
|
||
|
||
Дата ревью: 2026-07-25. Область: backend (C#/.NET 10), frontend (React 19), инфра.
|
||
Сборка чистая, 144 теста зелёные, typecheck чистый.
|
||
|
||
---
|
||
|
||
## 🔴 Критичное
|
||
|
||
- [x] **C1. Утечка слота семафора → медиа-обработка навсегда встаёт**
|
||
`MediaProcessingBackgroundService.cs:52` — слот берётся до `ClaimNextAsync`; бросок из claim
|
||
уводит во внешний `catch` без `Release()`. После N ошибок диспетчер зависает навсегда.
|
||
_Fix: try/finally вокруг claim либо Release() в ветке ошибки._
|
||
|
||
- [x] **C2. Слабый JWT-ключ по умолчанию без fail-fast**
|
||
`appsettings.json:8` + `DependencyInjection.cs:65` — placeholder-ключ, нет проверки на
|
||
переопределение и длину; тот же ключ у stream-токенов. Забыли → обход авторизации.
|
||
_Fix: на старте кидать, если ключ = placeholder или < 32 байт._
|
||
|
||
- [x] **C3. Инъекция/поломка ffmpeg-фильтра из пользовательских подписей**
|
||
`FfmpegBumperRenderer.cs:306` — `EscapeText` не экранирует `,` `;` `[` `]` `%` перевод строки.
|
||
Подпись с запятой рвёт filtergraph; `[`/`]` — инъекция звеньев. Валидатор проверяет только длину.
|
||
_Fix: экранировать полный набор метасимволов filtergraph._
|
||
|
||
---
|
||
|
||
## 🟠 Среднее
|
||
|
||
### Безопасность
|
||
- [x] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
|
||
- [x] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
|
||
- [x] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(TTL 30 мин + проверка блокировки на плейлисте; фронт перевыпускает cookie)_
|
||
- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`.
|
||
|
||
### Архитектура / транзакции
|
||
- [x] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs`, `ClearAllMediaCommandHandler.cs`. _(явная транзакция вокруг ExecuteDelete; файлы после коммита. DeleteAllShows — единичный ExecuteDelete, уже атомарен)_
|
||
- [x] **M6. Query с побочным эффектом на ФС** — переведён в `RenderBumperPreviewCommand` (command, не query).
|
||
- [x] **M7. Файловый/HTTP I/O до коммита** — Delete/Register хендлеры: БД коммитится раньше файлового I/O; Register откатывает регистрацию при сбое переноса файла.
|
||
|
||
### Планировщик / медиа
|
||
- [x] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
|
||
- [x] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
|
||
- [x] **M10. Рендер заставок синхронно внутри тика планировщика** — вынесен в асинхронную очередь: планировщик создаёт ассет заставки в Pending + кэш-строку и ставит в очередь, ffmpeg крутит новый `BumperRenderBackgroundService` вне тика/транзакции. До готовности плейлист отдаёт филлер.
|
||
- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
|
||
|
||
### Фронтенд
|
||
- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`.
|
||
- [x] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`.
|
||
- [x] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx`, `ChannelDetail.tsx`. _(пикеры дозагружают все страницы через `listAllMedia` с safety-cap; при упоре в cap — видимое предупреждение. True server-side pagination неуместна: пикеры делают клиентские исключение/парсинг/сортировку/bulk-select)_
|
||
- [x] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(разрешено анализом: сервер сам дедупит по `OriginalFileName` — клиент это зеркалит, а `skipped` показывается. Имя+размер разошлось бы с сервером → failed-загрузки. Уникальность по имени — доменное решение, кода не менял)_
|
||
|
||
---
|
||
|
||
## 🟡 Низкое / прочее
|
||
|
||
- [x] **L1. Синхронный I/O без CancellationToken в портах удаления** — все 5 delete-методов портов переведены на `Task`+`CancellationToken`; рекурсивные удаления каталогов офлоадятся (`Task.Run`).
|
||
- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`.
|
||
- [x] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode` кидает при обходе (+тест).
|
||
- [x] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — снят: генератор коммитит свою транзакцию, пост-save UnitOfWork стал no-op.
|
||
- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
|
||
- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`.
|
||
- [x] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`.
|
||
- [x] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs` (только Development либо флаг `Api:EnableOpenApi`).
|
||
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
|
||
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
|
||
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
|
||
- [x] **L12. Пробелы в тестах** — добавлен проект `TeleWave.Integration.Tests` на Testcontainers-Postgres: генерация расписания, M10 (Pending-заставки + очередь), M9 (advisory-lock против гонки), M7 (откат Register), M5 (транзакция DeleteShowMedia). При отсутствии Docker тесты пропускаются (SkippableFact). Плюс unit-тесты на M11/L3.
|
||
|
||
---
|
||
|
||
## Журнал исправлений
|
||
|
||
### 2026-07-25 — критические (C1–C3)
|
||
|
||
- **C1** — `MediaProcessingBackgroundService.cs`: `ClaimNextAsync` обёрнут в `try/catch`, слот
|
||
освобождается (`slots.Release()`) при любом сбое захвата перед `throw`. Теперь транзиентная
|
||
ошибка БД не «съедает» слот семафора — диспетчер не зависает.
|
||
- **C2** — `Infrastructure/DependencyInjection.cs`: после загрузки `JwtOptions` добавлен fail-fast:
|
||
старт падает с понятным сообщением, если `Jwt:SigningKey` короче 32 байт или содержит `change-me`
|
||
(значение-заглушка). Тот же ключ подписывает stream-токены, поэтому это закрывает и их.
|
||
- **C3** — `FfmpegBumperRenderer.cs`: подписи «Сейчас/Далее» (`NowLabel`/`NextLabel`) переведены с
|
||
инлайнового `text=` на `textfile=` (`nowlabel.txt`/`nextlabel.txt`, `expansion=none`), как уже
|
||
сделано для названий шоу. Метасимволы filtergraph в подписи больше не ломают/не инъектируют
|
||
цепочку. Неиспользуемый `EscapeText` удалён. Файлы чистятся в `finally`.
|
||
|
||
Проверка: `dotnet build` — 0 warnings/0 errors (при `TreatWarningsAsErrors`); тесты 95 + 49 зелёные.
|
||
|
||
### 2026-07-25 — механические средние/низкие (M1, M2, M4, M11, M12, M13, L2, L5, L6, L7, L10)
|
||
|
||
Backend:
|
||
- **M1** — `AuthEndpoints.cs`/`StreamingEndpoints.cs`: cookie-флаг `Secure` вычисляется через
|
||
`UseSecureCookie` (`!env.IsDevelopment() || request.IsHttps`) вместо голого `request.IsHttps`.
|
||
Вне Development cookie всегда `Secure` — прод за внешним TLS-прокси больше не отдаёт refresh/stream
|
||
cookie в открытую при неполной настройке ForwardedHeaders. В Development HTTP-разработка сохранена.
|
||
- **M2** — `appsettings.json`: добавлен override `System.Net.Http.HttpClient: Warning` — URI запросов
|
||
к TMDb/OMDb (с `api_key` в query) больше не пишутся в лог на уровне Information.
|
||
- **M4** — `Program.cs`: rate-limiter `auth` переведён на партиционирование по IP клиента
|
||
(`RateLimitPartition.GetFixedWindowLimiter`) — один клиент больше не исчерпывает окно логина для всех.
|
||
- **M11** — `SchedulePlannerModels.cs` + `ProgrammingOverride.cs`: `Covers` для еженедельного override
|
||
поддерживает окно через полночь (`EndMinute <= StartMinute` → `[start,24:00)` в day + `[0,end)` на
|
||
следующий день); `end == start` — пустое окно; добавлены null-guards. Регресс-тесты в
|
||
`ProgrammingOverrideTests` (+2, всего 97 domain).
|
||
- **L2** — `ValidationBehavior.cs`: `ValidateAsync(context, cancellationToken)` вместо синхронного
|
||
`Validate` — async-правила FluentValidation и отмена работают.
|
||
- **L6** — `InboxScannerBackgroundService.cs`: файлы, стабильно отклоняемые командой регистрации,
|
||
помечаются (`_failed`) и не регистрируются повторно каждые два тика; метка снимается при исчезновении файла.
|
||
- **L7** — `SchedulePlanner.cs`: веса шоу/вариантов суммируются в `long` с clamp до `int.MaxValue` —
|
||
экстремальные вес/множитель не переполняют int (иначе взвешенный выбор молча вырождался в первого).
|
||
- **L10** — `ImageDownloader.cs`: allowlist схемы (только абсолютные http/https) — подменённый ответ
|
||
провайдера не заставит сервер дёрнуть `file://`/`ftp://` и т.п.
|
||
|
||
Frontend:
|
||
- **M12** — `AirPage.tsx`: провал `watchChannel` показывает offline-панель с кнопкой ретрая (а не
|
||
бесконечный скелетон); ретрай (`attempt`) заново дёргает `watchChannel`.
|
||
- **M13** — `client.ts`: повторный 401 уже после успешного refresh вызывает `onUnauthorized()` —
|
||
мёртвая сессия чистит авторизацию, а не остаётся «залогиненной». Плюс guard на тело refresh-ответа
|
||
(`accessToken` должен быть строкой).
|
||
- **L5** — `ChannelPlayer.tsx`: fatal network/media HLS-ошибка сперва пробует восстановиться
|
||
(`startLoad`/`recoverMediaError`, до 3 попыток) и лишь затем уходит в offline; слушатели нативной
|
||
HLS-ветки снимаются в cleanup.
|
||
|
||
Отложено (требует решения владельца/дизайна): M3, M5–M10, M14, M15, L1, L3, L4, L8, L9, L11, L12 —
|
||
причины помечены у пунктов.
|
||
|
||
Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых
|
||
предупреждений.
|
||
|
||
### 2026-07-25 — остальные средние/низкие (M3, M5–M9, M14, L1, L3, L4, L8, L11 + разбор M10, M15, L9, L12)
|
||
|
||
По согласованным решениям (короткий TTL+проверка блокировки; конфиг-таймауты ffmpeg; Postgres
|
||
advisory-lock; явные транзакции + I/O после коммита):
|
||
|
||
- **M3** — `StreamingOptions.StreamTokenMinutes` (30), `StreamTokenService.Validate` возвращает id
|
||
зрителя; `LivePlaylist` сверяет блокировку через `IIdentityService` (1 запрос на перезагрузку
|
||
плейлиста, не на сегмент). Фронт (`AirPage`) перевыпускает cookie каждые 20 мин. Dev-ключ JWT
|
||
добавлен в `appsettings.Development.json`, чтобы C2 не ломал локальный запуск.
|
||
- **M5/M7** — `IAppDbContext.BeginTransactionAsync`; `ClearAllMedia`/`DeleteShowMedia` — транзакция
|
||
вокруг ExecuteDelete, файлы после коммита; `DeleteMediaAsset`/`DeleteImage`/`ClearBumperAudio`/
|
||
`RemoveBumperTemplate` — БД раньше файлов; `RegisterMediaAsset` — save→перенос с откатом при сбое.
|
||
- **M6** — `RenderBumperPreviewQuery` → `RenderBumperPreviewCommand` (+ файл/класс переименованы).
|
||
- **M8** — `ProcessRunner` принимает таймаут; `MediaOptions.ProbeTimeoutSeconds`/`TranscodeTimeoutSeconds`;
|
||
по таймауту процесс убивается, `TimeoutException` → ассет уходит в Failed, слот освобождается.
|
||
- **M9** — `IAppDbContext.AcquireChannelLockAsync` (`pg_advisory_xact_lock` по int64 из GUID канала);
|
||
`ScheduleGenerator.GenerateAsync` обёрнут транзакцией + lock, снял и **L4** (двойной save).
|
||
- **M14** — `listAllMedia` (дозагрузка всех страниц, safety-cap 5000) в пикерах ShowDetail/ChannelDetail
|
||
+ предупреждение при усечении (i18n ru/en). True server-pagination неуместна (клиентские
|
||
исключение/парсинг/сортировка/bulk).
|
||
- **L1** — 5 delete-методов портов → async с `CancellationToken`; рекурсивные удаления офлоадятся.
|
||
- **L3** — `Show.AddEpisode` кидает при второй серии для Single (+тест). **L8** — OpenAPI/Scalar только
|
||
вне прода/по флагу. **L11** — CLAUDE.md/README обновлены.
|
||
|
||
Разбор без правок кода:
|
||
- **M10** — частично закрыт (M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует
|
||
канал); полный вынос рендера из тика — отдельная архитектурная задача (флажок).
|
||
- **M15** — сервер сам дедупит по имени файла; клиент это зеркалит и показывает `skipped`. Смена на
|
||
имя+размер разошлась бы с сервером. Уникальность по имени — доменное решение.
|
||
- **L9** — dev-дефолты, переопределяются env в проде; C2 форсит JWT-ключ.
|
||
|
||
Новые/обновлённые опции: `.env.example` (Storage__StreamTokenMinutes, Media__Probe/TranscodeTimeoutSeconds,
|
||
Api:EnableOpenApi неявно). Тесты: +2 domain (override через полночь), +1 domain (Single).
|
||
|
||
Проверка: `dotnet build` 0/0 (TreatWarningsAsErrors); тесты **98 + 49** зелёные; frontend `tsc` чистый,
|
||
`oxlint` без новых предупреждений.
|
||
|
||
---
|
||
|
||
## Журнал — M10 (async-рендер заставок) и L12 (интеграционные тесты)
|
||
|
||
### 2026-07-25 — M10: асинхронная очередь рендера заставок
|
||
|
||
- **Домен**: `BumperAsset` получил `ChannelId`/`TemplateId`/`VariantId` (чтобы фоновый рендерер
|
||
восстановил спецификацию), фабрика обновлена; миграция `BumperAssetRenderContext` (Guid-колонки с
|
||
дефолтом для существующих строк — самовосстановление при перерендере). `MediaAsset.RegisterGenerated`
|
||
теперь честно Pending до готовности.
|
||
- **Очередь**: `IBumperRenderQueue` + `BumperRenderQueue` (сигнал поверх Channel, как у медиа).
|
||
- **`ScheduleBumperResolver`**: больше не рендерит — создаёт Pending-ассет (Source=Generated) + кэш-строку
|
||
и ставит в очередь; переиспользует Ready/Pending/Processing, перерендерит только Failed (по новейшей
|
||
кэш-строке). Тик планировщика и его транзакция больше не держатся во время ffmpeg (снимает и остаток M9).
|
||
- **`BumperRenderBackgroundService`**: последовательно берёт Pending Generated → восстанавливает спеку
|
||
(`BumperSpecFactory`) → ffmpeg → MarkReady/MarkFailed; сброс прерванных на старте; idle-poll 30с.
|
||
- **`MediaProcessingBackgroundService`**: Generated-ассеты исключены из claim/reset (их обслуживает
|
||
рендерер заставок).
|
||
- **Стриминг менять не пришлось**: `GetLivePlaylistQueryHandler` уже фильтрует по `Status==Ready` →
|
||
Pending-заставка автоматически деградирует в филлер, пока рендерится.
|
||
- **Поведение**: заставка перехода впервые появляется на тик позже (до этого — филлер); дальше берётся
|
||
из кэша. Осознанный компромисс (выбран владельцем).
|
||
|
||
### 2026-07-25 — L12: интеграционные тесты на Testcontainers-Postgres
|
||
|
||
Новый проект `tests/TeleWave.Integration.Tests` (в `.slnx`): поднимает `postgres:16-alpine`, применяет
|
||
реальные миграции, гоняет то, что InMemory не умеет (транзакции, advisory-lock, ExecuteDelete). 5 тестов:
|
||
генерация Program-расписания; создание Pending-заставок (Generated) + постановка в очередь (M10);
|
||
сериализация конкурентной генерации advisory-lock'ом без перекрытий (M9); откат Register при сбое
|
||
переноса файла (M7); атомарное удаление в DeleteShowMedia (M5). При отсутствии Docker — `SkippableFact`
|
||
пропускает (CI на раннере без Docker не падает; `dotnet test TeleWave.slnx` в CI прогонит их при наличии
|
||
Docker на ubuntu-latest).
|
||
|
||
Проверка: сборка решения 0/0; **98 domain + 49 application + 5 integration** зелёные; фронт `tsc`/`oxlint`
|
||
чистые.
|
||
|
||
---
|
||
|
||
## Осталось (доменное решение владельца, не дефект)
|
||
|
||
- **M15 (доменно)** — если понадобятся разные файлы с одинаковым именем: пересмотреть уникальность
|
||
`MediaAsset` (сейчас по `OriginalFileName` — намеренно, клиент это зеркалит).
|