From 3a2da3004b34c2b83ee96fae88ab110cf31b7dea Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 15 Aug 2026 13:51:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=91=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B5=D0=B9=20=D1=81=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BA=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=BC=D1=83=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D1=83=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса --- CLAUDE.md | 6 +- docs/adr/ADR-2026-08-15-app-namespace.md | 39 ++ docs/adr/ADR-2026-08-15-cursor-paging.md | 33 + .../ADR-2026-08-15-record-snapshot-columns.md | 37 ++ docs/adr/README.md | 3 + docs/architecture.md | 29 +- docs/conventions/errors.md | 34 +- docs/database.md | 44 +- docs/passport.md | 20 +- docs/review.md | 35 ++ docs/security.md | 24 +- .../202608150001_record_contract_columns.go | 94 +++ .../repo/pocketbase/migrations/migrations.go | 1 + .../adapter/repo/pocketbase/record_list.go | 201 ++++++ .../adapter/repo/pocketbase/record_mapping.go | 40 +- internal/archrules/arch_test.go | 52 ++ internal/contract/error.go | 37 ++ internal/contract/repository.go | 33 + internal/controller/http/app.go | 575 ++++++++++++++++++ internal/controller/http/auth.go | 12 + internal/controller/http/auth_test.go | 14 +- internal/controller/http/contract_test.go | 427 +++++++++++++ internal/controller/http/errors.go | 235 +++++++ internal/controller/http/list_test.go | 436 +++++++++++++ internal/controller/http/login_test.go | 9 +- internal/controller/http/ownership_test.go | 14 +- internal/controller/http/rate_limit.go | 58 ++ internal/controller/http/status_test.go | 222 +++++-- internal/controller/http/transcribe.go | 169 ----- internal/controller/http/transcribe_test.go | 120 ++-- internal/entity/audio_record.go | 63 ++ internal/entity/filename_test.go | 69 +++ internal/entity/stage.go | 53 ++ internal/entity/text.go | 30 + internal/metrics/format_label.go | 28 + internal/service/find_job_test.go | 8 + internal/service/transcribe.go | 44 +- journal_route_test.go | 8 +- main.go | 12 +- .../.openspec.yaml | 2 + .../2026-08-15-app-json-contract/design.md | 219 +++++++ .../2026-08-15-app-json-contract/proposal.md | 70 +++ .../review/code-review.md | 101 +++ .../review/design-review.md | 97 +++ .../specs/access/spec.md | 141 +++++ .../specs/archive/spec.md | 466 ++++++++++++++ .../specs/intake/spec.md | 280 +++++++++ .../specs/pipeline/spec.md | 95 +++ .../specs/storage/spec.md | 104 ++++ .../2026-08-15-app-json-contract/tasks.md | 189 ++++++ openspec/specs/access/spec.md | 67 +- openspec/specs/archive/spec.md | 470 ++++++++++++++ openspec/specs/intake/spec.md | 217 ++++--- openspec/specs/pipeline/spec.md | 31 +- openspec/specs/storage/spec.md | 55 +- 55 files changed, 5506 insertions(+), 466 deletions(-) create mode 100644 docs/adr/ADR-2026-08-15-app-namespace.md create mode 100644 docs/adr/ADR-2026-08-15-cursor-paging.md create mode 100644 docs/adr/ADR-2026-08-15-record-snapshot-columns.md create mode 100644 internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go create mode 100644 internal/adapter/repo/pocketbase/record_list.go create mode 100644 internal/controller/http/app.go create mode 100644 internal/controller/http/contract_test.go create mode 100644 internal/controller/http/errors.go create mode 100644 internal/controller/http/list_test.go create mode 100644 internal/controller/http/rate_limit.go delete mode 100644 internal/controller/http/transcribe.go create mode 100644 internal/entity/filename_test.go create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/design.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/proposal.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/review/code-review.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/review/design-review.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/specs/access/spec.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/specs/archive/spec.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/specs/intake/spec.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/specs/pipeline/spec.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-15-app-json-contract/tasks.md create mode 100644 openspec/specs/archive/spec.md diff --git a/CLAUDE.md b/CLAUDE.md index 3f25f76..7efc98a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,11 +56,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- остаток — [docs/security.md](docs/security.md). - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет запись пригодной к повтору, либо ставит на неё признак остановки с причиной — - и тогда причина видна её владельцу опросом готовности, а владельцу сервиса + и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса журналом. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и - отправитель, который не спрашивает, о нём не узнаёт. **major** + отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он + спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком + переехала на карточку. **major** - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** diff --git a/docs/adr/ADR-2026-08-15-app-namespace.md b/docs/adr/ADR-2026-08-15-app-namespace.md new file mode 100644 index 0000000..01226fc --- /dev/null +++ b/docs/adr/ADR-2026-08-15-app-namespace.md @@ -0,0 +1,39 @@ +# Приложение живёт своим пространством адресов, а не общим с хранилищем + +- **Дата:** 2026-08-15 +- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md, + раздел «Переезд в `/app/`, а слой сессии — на корень» + +## Решение + +Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой +предъявления сессии повешен на **группу корня**, а не на перечень адресов. + +## Почему + +Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы +адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка. +Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки +вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом +начнёт отвечать не тот обработчик. + +Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*` +решением от 2026-08-12. + +Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом +приложения, и забытый в нём адрес молча перестал бы принимать куку». + +## Последствия + +- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших + адресов больше не задевает; +- `+` новый адрес приложения получает слой предъявления по построению, а не по + памяти того, кто его добавил; +- `−` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо + одного: `/api/`, `/app/`, `/auth/` и `/_/`; +- `−` ограничитель частоты хранилища, настроенный на его собственный корень, + наших адресов не покрывает — своё правило заводится нами, и его включение + вводит в действие заодно умолчательные правила хранилища; +- `−` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено + стадией — на сервере данных нет, внешней программы на прежнем контракте не + существует. diff --git a/docs/adr/ADR-2026-08-15-cursor-paging.md b/docs/adr/ADR-2026-08-15-cursor-paging.md new file mode 100644 index 0000000..b6eb6ed --- /dev/null +++ b/docs/adr/ADR-2026-08-15-cursor-paging.md @@ -0,0 +1,33 @@ +# Страница архива задаётся ключом, а не номером + +- **Дата:** 2026-08-15 +- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md, + раздел «Страница задаётся ключом, а не номером» + +## Решение + +Постраничное чтение своих записей идёт непрозрачным ключом по паре «время +заведения и идентификатор». Номер страницы отвергнут. + +## Почему + +«Приём пишет в голову той же таблицы записей, которую читает список, и человек, +загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы +сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым +разом первым элементом второй, а один элемент между ними не пришёл бы никогда. +Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в +котором записи нет.» + +Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок +между ними одним лишь временем не определён. + +## Последствия + +- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни + пропуска; +- `+` порядок между записями с равным временем устойчив; +- `−` экран с нумерацией страниц так не сделать — листать можно только + «дальше». Архиву это не нужно; +- `−` ключ приходит от клиента и потому разбирается: время приводится к виду + хранилища, иначе побайтовое сравнение молча обращает условие в постоянную + истину или ложь. diff --git a/docs/adr/ADR-2026-08-15-record-snapshot-columns.md b/docs/adr/ADR-2026-08-15-record-snapshot-columns.md new file mode 100644 index 0000000..db6ad57 --- /dev/null +++ b/docs/adr/ADR-2026-08-15-record-snapshot-columns.md @@ -0,0 +1,37 @@ +# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается + +- **Дата:** 2026-08-15 +- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md, + раздел «Три новых колонки записи и один шаг схемы» + +## Решение + +Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины +уже есть у строки её файла. Равенство между ними не поддерживается никем — +намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль. + +## Почему + +Обе величины показываются в списке, а список по норме `storage` читается без +содержимого. Ревью дизайна возражало: величины станут копиями, которые некому +держать равными. Решением владельца колонки остались, а равенство объявлено +**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на +файле — величины той копии, которой файл является сейчас». Уточнение +длительности — перечитали метаданные, сменили источник, нарезали длинную запись +— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и +«что лежит сейчас». + +Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая +колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём. +Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел +не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными +метаданными отвергается отказом и не заводится. + +## Последствия + +- `+` страница списка не читает по строке файла на каждую запись; +- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается; +- `−` в применённом шаге схемы навсегда остаются две колонки, повторяющие + величины строки файла; расхождение между ними — не поломка, и заметить его + нечем; +- `−` запись, заведённая рукой в панели без величин, покажет человеку ноль. diff --git a/docs/adr/README.md b/docs/adr/README.md index 127350e..d843099 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,9 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | | +| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | | +| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | | | 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | | | 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | | | 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index f639569..7e16b00 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ [conventions/go-linters.md](conventions/go-linters.md). - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие - входов**: приём и опрос за сессией, имя отправителя не доходит ни до + входов**: приём за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, метка метрики несёт только известное расширение, а наблюдатель видит единственный поднятый вход. Задачи `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, @@ -38,6 +38,13 @@ целиком и вложением, как из сохранённого строится структура реплик без повторной оплаты и почему разбор формата провайдера не доходит до конвейера. Задача `record-centric-model` 2026-08-14; +- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами + приложения**: пространство адресов `/app/` и единая форма отказа с + машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само + чтение — страница записей ключом, карточка без текста и текст названного вида. + Здесь же обязанность, переехавшая с убранного опроса готовности: причину + остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa` + 2026-08-15; - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` @@ -82,7 +89,7 @@ | Компонент | Где | Что делает | | --- | --- | --- | -| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | +| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | @@ -139,15 +146,15 @@ | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | - | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста | + | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | -- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности: - остановленная запись отдаёт признак остановки. Владелец — по метрике +- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи: + остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage` называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров имя потока перестало что-либо значить, а разрез по шагу — единственное, чем @@ -177,11 +184,15 @@ | Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Метрики | `internal/metrics`, префикс имени `transcriber_` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | +| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих | +| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса | +| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём | Единых точек, которых **нет** и которые ожидались бы: идентификаторы -генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в -код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло -2026-08-13: его читает `internal/clock`, и запрет держит линтер. +генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло +2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение +доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик +решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл. ## Деплой @@ -214,7 +225,7 @@ входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор компонентов. -- **Уведомления.** Пользователь веба узнаёт о готовности только опросом. +- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 227541a..38e5b98 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -98,20 +98,36 @@ transcriber — **приложение, а не библиотека**: внеш - **отображение доменной ошибки в статус и сообщение** — единой точкой для HTTP и веба: - | Доменная ошибка | Статус | Сообщение | - | --- | --- | --- | - | задача не найдена | 404 | «задача не найдена» | - | файл не приложен, формат не распознан | 400 | «некорректный ввод» | - | задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» | - | прочее | 500 | «внутренняя ошибка» | + | Доменная ошибка | Статус | `error_code` | Сообщение | + | --- | --- | --- | --- | + | сессии нет | 401 | `unauthorized` | «требуется вход» | + | предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» | + | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» | + | файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» | + | запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом | + | запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» | + | текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» | + | прочее | 500 | `internal` | «внутренняя ошибка» | Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а логирующая граница спишет его в `ERROR` вместо `DEBUG`. - *Расхождение:* такой точки нет. `internal/controller/http/transcribe.go` - отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на - любую ошибку заведения задачи. + **Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не + хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три + `400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы + был бы единственным оставшимся путём. Норму держит спека `archive`. + + Точка живёт в `internal/controller/http.mapDomainError` и названа в + [architecture.md](../architecture.md), «Единые точки проекта». Прежнее + расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей + `json-api-for-spa` 2026-08-15. + + **Часть отказов рождается не в обработчике** — предел тела, ограничитель + частоты, неизвестный путь под корнем приложения — и до этой точки не доходит + вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех + прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети + приходил бы телом библиотеки. ### Разовый ответ и сохранённая диагностика diff --git a/docs/database.md b/docs/database.md index 5aa7bdb..669f623 100644 --- a/docs/database.md +++ b/docs/database.md @@ -69,6 +69,9 @@ capability, и третий смысл развёл бы одно слово п | `owner` | relation → `users` | Владелец записи; пустого значения не принимает | | `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | +| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки | +| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда | +| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | | `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | @@ -88,8 +91,39 @@ capability, и третий смысл развёл бы одно слово п | `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается | | `created`, `updated` | DATETIME | Проставляет хранилище | -Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, -паузе и сроку протухания. +Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и +сроку протухания идёт выборка захвата. Два других завела страница списка приложения: +`(owner, created DESC, id DESC)` под страницу «новыми сверху» и +`(owner, state, halted_at)` под отбор тремя состояниями. + +**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает +ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса +страница сканировала таблицу целиком и досортировывала результат во временном +дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы +владельца в двадцать-тридцать раз — при неизменных сорока его собственных +записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и +хранит их бессрочно. + +**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое +дал человек либо посчитала языковая модель; имя файла — то, по чему человек +узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное +название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит +извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя +файла в хранилище и в журнал оно по-прежнему не идёт. + +**Длительность и размер лежат и на записи, и на её файле, и равенство между ними +не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый +приёмом один раз; на файле — величины нынешней копии файла. +Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — +«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, +что показываются в списке, а список читается без содержимого. Решение владельца +от 2026-08-15. + +**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.** +Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём. +Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел +не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными +метаданными отвергается отказом и не заводится вовсе. **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем @@ -287,6 +321,12 @@ capability, и третий смысл развёл бы одно слово п | Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | | Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | | Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | +| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана | +| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром | +| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи | +| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет | +| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт | +| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | | Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | | Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | diff --git a/docs/passport.md b/docs/passport.md index 4b4cddf..4cb2a5e 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -92,16 +92,18 @@ Telegram. 2. **Возвращение к записи.** Через месяц человек открывает список, находит запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую расшифровку. -3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, - получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не - увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: - анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — - его заводит `api-tokens`. Записи при этом разграничены: программа с чужой - сессией видит только записи того, чью сессию предъявила. +3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим + токеном, получает идентификатор записи и читает её карточку + `GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным + адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только + предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего + входа у программы нет — его заводит `api-tokens`. Записи при этом + разграничены: программа с чужой сессией видит только записи того, чью сессию + предъявила. 4. **Отказ на середине.** Конвертация или распознавание не удались — запись - получает признак остановки с причиной, и опрос готовности отдаёт этот признак - тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка - ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`. + получает признак остановки с причиной, и карточка записи отдаёт признак и + причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: + доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`. ## Референсы diff --git a/docs/review.md b/docs/review.md index e205436..cb96cf6 100644 --- a/docs/review.md +++ b/docs/review.md @@ -172,6 +172,18 @@ - `conventions`: новая колонка правится в обоих местах репозитория, а новый рубеж — одним дескриптором (CLAUDE.md, «Инварианты»). +- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по + прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и + остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала + 2026-08-15 про единую форму отказа). +- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи + заведён под захват воркера — по рубежу и признаку остановки, — и выборке, + сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное + сканирование таблицы и рост времени страницы вместе с **чужими** записями. +- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик + ограничителя частоты. Запрос, собранный руками, приходит без адреса, а + вырожденное значение библиотека отдаёт не пустой строкой, и её собственный + страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15). - `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим** тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по [conventions/go-linters.md](conventions/go-linters.md), «Механизировано»; @@ -280,6 +292,29 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью] + +- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача + `json-api-for-spa` +- **Симптом:** три отказа под корнем приложения — превышение + потолка тела, ограничитель частоты и неизвестный путь — уходили телом + библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа + на адресах приложения было две, а не одна, — ровно то, ради чего задача и + заводилась +- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то, + что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ + слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к + которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял + все три случая как закрытые +- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон. + Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и + сочинил, — запроса он не слал и потому оставался зелёным независимо от того, + что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного + производителя в рабочем коде +- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий + запрос**; вызов отображателя напрямую формой ответа не является. Добавлено + вопросом в раздел ниже + ## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью] - **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put` diff --git a/docs/security.md b/docs/security.md index e02b306..766362f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -3,7 +3,7 @@ ## Периметр **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через -обратный прокси, а приём записи, опрос готовности и файл записи требуют входа +обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты без входа только проба здоровья и метрики. Находки строятся против этого — сегодняшнего — периметра. @@ -11,7 +11,7 @@ Целевой периметр добавляет к нему отдельный вход для программ по личным токенам и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14** -задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем +задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня доступа — страницы расхода для владельца сервиса. @@ -46,9 +46,11 @@ Telegram — связи чата с учётной записью сервис новое: **чтение файла базы теперь равносильно чтению секрета клиента**. Отсюда главное следствие, из которого читается всё остальное: **`POST -/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не -ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги -на распознавание столько, сколько захочет. +/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число +же запросов ограничено только частотой**. Вошедший тратит наши деньги на +распознавание столько, сколько захочет: ограничитель частоты под корнем +приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму +по-прежнему нет — её заводит `per-user-size-quota`. ## Недоверенный вход @@ -56,8 +58,10 @@ Telegram — связи чата с учётной записью сервис | Вход | Канал | Кто может слать | | --- | --- | --- | -| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | -| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | +| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков | +| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи | +| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу | +| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | @@ -81,8 +85,8 @@ Telegram — связи чата с учётной записью сервис Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез -2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу -готовности и в панели владельца. +2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом +и в панели владельца. Целевой периметр добавляет три пути, каждый — своей задачей: @@ -130,7 +134,7 @@ Storage, оттуда его читает SpeechKit. Третий путь — — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. - **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное, - что защищает `GET /api/status/:id`. + что защищает карточку записи и её текст. - **Поверхность самого хранилища.** Вместе с переводом наружу выходят `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то diff --git a/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go b/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go new file mode 100644 index 0000000..09b6b12 --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go @@ -0,0 +1,94 @@ +package migrations + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/core" + + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// up202608150001 заводит у аудиозаписи три колонки, которые показывает список +// приложения: имя файла, данное отправителем, длительность и размер принятого. +// +// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага +// вместо одного стоили бы трёх необратимых решений там, где хватает одного. +// +// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт +// название, которое дал человек либо посчитала языковая модель; имя файла — то, +// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба +// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы +// неоткуда. +// +// **Длительность и размер дублируют строку файла, и это решение владельца от +// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит +// снимок принятого, взятый приёмом один раз, на файле — величины той копии, +// которой файл является сейчас. Расхождение — не поломка, а разные вопросы; +// норму держит capability `storage`. +// +// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против +// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит +// ни компилятор, ни гейт — оба конца числа. +func up202608150001(app core.App) error { + records, err := app.FindCollectionByNameOrId(RecordsCollection) + if err != nil { + return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err) + } + + records.Fields.Add( + // Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому + // приём режет его по пределу и убирает управляющие знаки прежде, чем + // сохранить. Схема держит потолок вторым рубежом: значение сверх него + // отвергается хранилищем, а не доезжает до экрана. + &core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen}, + // Длительность и размер принятого. «Неизвестно» колонки не выражают: + // числовая колонка хранилища пустого значения не держит, пустое кладётся + // нулём. Обе ставит приём и ставит всегда — запись с непрочитанными + // метаданными отвергается отказом и не заводится. Решение владельца + // 2026-08-15. + &core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)}, + &core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)}, + ) + + // Индекс под ленту приложения. Единственный прежний индекс — по рубежу и + // признаку остановки — заведён под захват воркера и выборке владельца не + // помогает ничем: страница сканирует таблицу целиком и досортировывает + // результат во временном дереве. + // + // Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч — + // сорокакратный — растит время одной страницы владельца в двадцать-тридцать + // раз, хотя записей у него всё те же сорок. Цена растёт с **чужими** + // записями, потому что сервис объявлен архивом и хранит их бессрочно. + // + // Порядок колонок повторяет порядок выборки: сужение по владельцу, затем + // сортировка «новыми сверху» полным ключом. + records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "") + // Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком + // остановки — своим индексом, потому что ведущей колонкой здесь владелец. + records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "") + + if err := app.Save(records); err != nil { + return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err) + } + return nil +} + +// down202608150001 снимает три колонки. Данные в них при этом теряются, и +// восстановить их неоткуда: имя файла отправителя нигде больше не хранится. +func down202608150001(app core.App) error { + records, err := app.FindCollectionByNameOrId(RecordsCollection) + if err != nil { + return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err) + } + + for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} { + records.Fields.RemoveByName(name) + } + records.RemoveIndex("idx_audio_records_owner_feed") + records.RemoveIndex("idx_audio_records_owner_state") + + if err := app.Save(records); err != nil { + return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err) + } + return nil +} diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go index 95bfbce..0a34c94 100644 --- a/internal/adapter/repo/pocketbase/migrations/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations/migrations.go @@ -50,6 +50,7 @@ func init() { pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go") pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go") + pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go") } func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/record_list.go b/internal/adapter/repo/pocketbase/record_list.go new file mode 100644 index 0000000..6856e4b --- /dev/null +++ b/internal/adapter/repo/pocketbase/record_list.go @@ -0,0 +1,201 @@ +package pocketbase + +import ( + "fmt" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// defaultListLimit — умолчание, к которому приводится непозитивный предел. +// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает. +const defaultListLimit = 30 + +// List отдаёт страницу записей владельца, новыми сверху. +// +// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же +// ленты, которую читает список, и человек, загрузивший запись и листающий свой +// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний +// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один +// элемент между ними не пришёл бы никогда — и оба раза молча. +// +// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у +// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не +// определён вовсе. +// +// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от +// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся +// колонками самой записи. +func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) { + // Пустой владелец не совпадает ни с одной записью. Правило записано со + // стороны спрашивающего: обязательность, которую держит одна лишь схема, + // пустую строку пропустила бы. + if q.OwnerID == "" { + return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil + } + + // Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже + // стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1. + // Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй + // вызывающий с забытым полем структуры получил бы панику, а восстановления у + // воркеров нет вовсе. + if q.Limit <= 0 { + q.Limit = defaultListLimit + } + + collection, err := findCollection(repo.app, migrations.RecordsCollection) + if err != nil { + return nil, err + } + + filter := dbx.HashExp{"owner": q.OwnerID} + conditions := []dbx.Expression{filter} + + state, err := stateCondition(q.Filter) + if err != nil { + return nil, err + } + if state != nil { + conditions = append(conditions, state) + } + + total, err := repo.countRecords(conditions) + if err != nil { + return nil, err + } + + // Курсор режет ленту по паре: строго раньше по времени, а при равном времени + // — строго меньше по идентификатору. Идентификаторы хранилища монотонны в + // пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и + // этого довольно: задача ключа — не пропустить и не повторить. + if q.Cursor != nil { + conditions = append(conditions, dbx.Or( + dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), + dbx.And( + dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), + dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}), + ), + )) + } + + // Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли + // следующая страница» без второго запроса и без вычислений по общему числу, + // которое к этому моменту могло измениться. + records := []*core.Record{} + err = repo.app.RecordQuery(collection). + AndWhere(dbx.And(conditions...)). + OrderBy("created DESC", "id DESC"). + Limit(int64(q.Limit) + 1). + All(&records) + if err != nil { + return nil, fmt.Errorf("failed to list audio records: %w", err) + } + + page := &contract.RecordPage{TotalItems: total} + if len(records) > q.Limit { + last := records[q.Limit-1] + page.NextCursor = &contract.RecordCursor{ + CreatedAt: last.GetDateTime("created").String(), + ID: last.Id, + } + records = records[:q.Limit] + } + + page.Items = make([]*entity.AudioRecord, 0, len(records)) + for _, record := range records { + page.Items = append(page.Items, recordToAudioRecord(record)) + } + + return page, nil +} + +// stateCondition переводит состояние отбора в условие запроса. +// +// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор +// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе +// молча поменял бы состав всех трёх состояний. +func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) { + if filter == nil { + return nil, nil + } + + notHalted := dbx.NewExp("halted_at = ''") + halted := dbx.NewExp("halted_at != ''") + + switch *filter { + case entity.ListFilterHalted: + return halted, nil + case entity.ListFilterWorking: + return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil + case entity.ListFilterDone: + return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil + } + + // Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в + // перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив + // под именем отбора — и заметить это было бы нечем. + return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter) +} + +func stageNameValues(stages []entity.Stage) []any { + names := entity.StageNames(stages) + out := make([]any, 0, len(names)) + for _, n := range names { + out = append(out, n) + } + return out +} + +func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) { + var counter struct { + Total int `db:"total"` + } + err := repo.app.RecordQuery(migrations.RecordsCollection). + Select("count(*) as total"). + AndWhere(dbx.And(conditions...)). + One(&counter) + if err != nil { + return 0, fmt.Errorf("failed to count audio records: %w", err) + } + return counter.Total, nil +} + +// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а +// не по запросу на запись: страница в сотню записей иначе стоила бы сотни +// обращений к хранилищу. +// +// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы +// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради +// чего контракт согласуется один раз. +func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) { + out := map[string]string{} + if len(ids) == 0 || ownerID == "" { + return out, nil + } + + values := make([]any, 0, len(ids)) + for _, id := range ids { + values = append(values, id) + } + + // Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — + // пара «владелец и название» уникальна, — и разрешение без сужения отдало бы + // название чужой темы, как только темы начнёт писать языковая модель. + records := []*core.Record{} + err := repo.app.RecordQuery(migrations.TopicsCollection). + AndWhere(dbx.HashExp{"owner": ownerID}). + AndWhere(dbx.In("id", values...)). + All(&records) + if err != nil { + return nil, fmt.Errorf("failed to resolve topics: %w", err) + } + + for _, record := range records { + out[record.Id] = record.GetString("name") + } + return out, nil +} diff --git a/internal/adapter/repo/pocketbase/record_mapping.go b/internal/adapter/repo/pocketbase/record_mapping.go index aa804a6..1848899 100644 --- a/internal/adapter/repo/pocketbase/record_mapping.go +++ b/internal/adapter/repo/pocketbase/record_mapping.go @@ -54,6 +54,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) { record.Set("source", r.Source) record.Set("title", derefString(r.Title)) record.Set("brief", derefString(r.Brief)) + // Имя файла отправителя, длительность и размер кладёт приём и только он: это + // снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их + // нет намеренно — снимок шага, записанный поверх, стёр бы их молча. + record.Set("original_filename", derefString(r.OriginalFilename)) + record.Set("duration_ms", numberOrZero(r.DurationMs)) + record.Set("size_bytes", numberOrZero(r.SizeBytes)) + // Темы кладутся при заведении пустыми и конвейером не трогаются: считает их + // языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением — + // колонка, которую читают и не пишут, ничем не отличима от забытой. + record.Set("topics", r.TopicIDs) } func recordToAudioRecord(record *core.Record) *entity.AudioRecord { @@ -78,8 +88,15 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord { LiteraryTextID: nilIfEmpty(record.GetString("literary_text")), StructureID: nilIfEmpty(record.GetString("structure")), RecognitionID: nilIfEmpty(record.GetString("recognition")), - CreatedAt: record.GetDateTime("created").Time(), - UpdatedAt: record.GetDateTime("updated").Time(), + OriginalFilename: nilIfEmpty(record.GetString("original_filename")), + // Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент + // помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а + // инвариант о колонках компилятор не проверяет. + DurationMs: numberValue(record.GetInt("duration_ms")), + SizeBytes: numberValue(record.GetInt("size_bytes")), + TopicIDs: record.GetStringSlice("topics"), + CreatedAt: record.GetDateTime("created").Time(), + UpdatedAt: record.GetDateTime("updated").Time(), } } @@ -103,6 +120,25 @@ func dateOrEmpty(v *time.Time) any { return date } +// numberOrZero отдаёт ноль вместо отсутствующего числа. +// +// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она +// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака +// либо текстового типа у чисел, и платить за это нечем — обе величины ставит +// приём и ставит всегда. Решение владельца 2026-08-15. +func numberOrZero(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero. +func numberValue(value int) *int64 { + v := int64(value) + return &v +} + func nilIfEmpty(v string) *string { if v == "" { return nil diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index bf54d33..376e536 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -260,6 +260,39 @@ func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T // Обратное направление того же правила: шаг, написанный под рубеж, которого в // дескрипторе нет, недостижим — захват такую запись не выдаст никогда. +// Отбор списка — очередной потребитель словаря рубежей, и перечислять их у него +// строкой запроса нельзя: рубеж, добавленный конвейером, молча поменял бы состав +// всех трёх состояний отбора, а заметить это было бы нечем. +// +// Правило смотрит, что выборка списка берёт рубежи у дескриптора, а не пишет их +// литералом. Инвариант проекта «Рубеж объявляется одним дескриптором» компилятор +// не проверяет — проверяет оно. +func TestОтборСпискаБерётРубежиУДескриптора(t *testing.T) { + const listFile = repoPkg + "/record_list.go" + + body := readFile(t, listFile) + + for _, value := range declaredStateValues(t) { + if strings.Contains(body, `"`+value+`"`) { + t.Errorf( + "отбор списка называет рубеж %q строкой: рубеж, добавленный "+ + "дескриптором, молча не попадёт ни в одно состояние отбора", + value, + ) + } + } + + for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} { + if !strings.Contains(body, fn) { + t.Errorf( + "отбор списка не зовёт %s: перечень рубежей обязан приходить из "+ + "дескриптора, а не собираться по месту", + fn, + ) + } + } +} + func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) { body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") declared := map[string]bool{} @@ -363,6 +396,25 @@ func stageDescriptor(t *testing.T) (all []string, working []string) { } // declaredStates — константы рубежей, объявленные доменом. +// declaredStateValues — **значения** рубежей, а не имена их констант: правило +// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со +// значением. +// +// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие +// код, а не строки. +func declaredStateValues(t *testing.T) []string { + t.Helper() + out := []string{} + re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`) + for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) { + out = append(out, m[1]) + } + if len(out) == 0 { + t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile) + } + return out +} + func declaredStates(t *testing.T) map[string]bool { t.Helper() out := map[string]bool{} diff --git a/internal/contract/error.go b/internal/contract/error.go index bbcab12..3ca86bf 100644 --- a/internal/contract/error.go +++ b/internal/contract/error.go @@ -10,6 +10,43 @@ import ( // него не кладётся никогда. var ErrOwnerRequired = errors.New("owner is required to accept a record") +// ErrRecordUnreadable — присланную запись не удалось прочитать: источник +// метаданных не разобрал её содержимое. Причина отказа — сама запись, а не сбой +// сервиса, и код ответа обязан называть причину, а не место. +// +// Заводится sentinel'ом, а не остаётся голой ошибкой источника метаданных: +// ветвь по умолчанию отдала бы `500`, и «файл негоден» читалось бы как «сломался +// сервер». Своих данных отказу нести нечего — имя файла в него не кладётся +// никогда. +var ErrRecordUnreadable = errors.New("uploaded record cannot be read") + +// ErrRecordTooLarge — присланная запись длиннее потолка размера. Самый частый +// отказ у человека на мобильной сети, и прежде он уходил телом ограничителя тела +// — мимо единой формы отказа. +var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit") + +// ErrTextNotReady — текста запрошенного вида у записи ещё нет. Состояние, а не +// отсутствие: запись есть и принадлежит спрашивающему, просто конвейер до этого +// вида не дошёл. Отвечать на это тем же, чем отвечает чужая запись, нельзя — +// человек увидел бы «не найдено» на своей записи, загруженной минуту назад. +var ErrTextNotReady = errors.New("requested text view is not ready yet") + +// ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста, +// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable +// тем, что негодна **просьба**, а не присланная запись. +var ErrBadRequest = errors.New("request input is not valid") + +// ErrUnauthorized — сессии нет вовсе. Первая строка таблицы отображения, и без +// собственного признака она собиралась бы руками мимо единой точки: правка формы +// тела не доехала бы до неё, и два места разошлись бы молча. +var ErrUnauthorized = errors.New("session is required") + +// ErrNotFound — под корнем приложения такого адреса нет. Отличается от +// JobNotFoundError тем, что не найдена **просьба**, а не запись: тело у ответа +// то же, но повод другой, и смешивать их в одном признаке значило бы называть +// отсутствующий адрес отсутствующей записью. +var ErrNotFound = errors.New("address not found") + type JobNotFoundError struct { State string Message string diff --git a/internal/contract/repository.go b/internal/contract/repository.go index c7ed5fd..6cf18e4 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -71,8 +71,41 @@ type AcquiredRecord struct { Holder string } +// RecordCursor — положение в ленте записей, заданное **полным** ключом +// сортировки. Одного времени мало: у записей, принятых одним запросом, оно +// совпадает, и порядок между ними иначе не определён. +type RecordCursor struct { + CreatedAt string + ID string +} + +// RecordQuery — что спрашивают у ленты записей. +type RecordQuery struct { + // OwnerID обязателен: пустой не совпадает ни с одной записью. + OwnerID string + // Filter — состояние записи. Пустой значит «все». + Filter *entity.ListFilter + // Cursor — положение, с которого продолжать. Пустой значит «сначала». + Cursor *RecordCursor + Limit int +} + +// RecordPage — страница ленты. Ключ следующей страницы пуст, когда страница +// последняя. +type RecordPage struct { + Items []*entity.AudioRecord + NextCursor *RecordCursor + TotalItems int +} + type AudioRecordRepository interface { Create(record *entity.AudioRecord) error + // List отдаёт страницу записей владельца, новыми сверху, не читая ни + // расшифровки, ни структуры реплик. + List(q RecordQuery) (*RecordPage, error) + // ResolveTopicNames разрешает темы названиями одним запросом на страницу и + // сужает их владельцем: словарь тем свой у каждого человека. + ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) // Save сохраняет запись, захват которой держит holder. Захват, доставшийся // за время работы другому, даёт LostAcquisitionError и запись не проводит. // Пустой holder снимает эту условность и в конвейере не употребляется: все diff --git a/internal/controller/http/app.go b/internal/controller/http/app.go new file mode 100644 index 0000000..1f0a78f --- /dev/null +++ b/internal/controller/http/app.go @@ -0,0 +1,575 @@ +package http + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" + "github.com/pocketbase/pocketbase/tools/types" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/metrics" + "git.vakhrushev.me/av/transcriber/internal/service" +) + +// AppRoot — корень адресов приложения. +// +// Приложение живёт своим пространством, а не в общем `/api/`: последнее +// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять +// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён +// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с +// нашим, и разойдутся они молча. +const AppRoot = "/app" + +// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона +// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив +// одним запросом и тем обойти постраничность её же параметром. +const ( + DefaultPageLimit = 30 + MaxPageLimit = 100 +) + +// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки. +// +// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает +// список, открывает карточку соседней записи и грузит новую, а бюджет +// ограничителя один на все адреса приложения и считается по адресу +// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят +// его пополам. +const pollBudgetShare = 8 + +// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку. +// +// Выводится из настройки ограничителя частоты под корнем приложения, а не +// задаётся своей константой: иначе приложение, честно опрашивающее карточку с +// объявленной частотой, упирается в ограничитель сервиса — и получает отказ, +// которого сервис сам же ему обещал избежать. +// +// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один +// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная +// частота — доля бюджета, и неравенство «объявленное меньше применяемого» +// выполняется с запасом. +const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests) + +type AppHandler struct { + recordRepo contract.AudioRecordRepository + textRepo contract.TextRepository + structureRepo contract.StructureRepository + trsService *service.TranscribeService + logger *slog.Logger +} + +func NewAppHandler( + recordRepo contract.AudioRecordRepository, + textRepo contract.TextRepository, + structureRepo contract.StructureRepository, + trsService *service.TranscribeService, + logger *slog.Logger, +) *AppHandler { + if logger == nil { + logger = slog.Default() + } + return &AppHandler{ + recordRepo: recordRepo, + textRepo: textRepo, + structureRepo: structureRepo, + trsService: trsService, + logger: logger, + } +} + +// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы +// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о +// другую. +// +// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса. +// Причина остановки — значение из закрытого перечня, и она не он: без причины +// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения +// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном. +type RecordView struct { + ID string `json:"id"` + Title *string `json:"title"` + OriginalFilename *string `json:"original_filename"` + Brief *string `json:"brief"` + Topics []string `json:"topics"` + State string `json:"state"` + Halted bool `json:"halted"` + HaltReason *string `json:"halt_reason"` + DurationMs *int64 `json:"duration_ms"` + SizeBytes *int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` + // AvailableViews — перечень доступных видов текста, а не признак «текст + // есть». Видов больше одного, и шаг завершения пишет их несколькими + // операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один + // признак отправил бы приложение за репликами, которых нет, и исход стал бы + // функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет». + // + // У элемента страницы поле опущено: страница видов не читает. + AvailableViews *[]string `json:"available_views,omitempty"` +} + +// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла. +// +// Место под признак заведено вперёд и заполняется другой задачей. Форма +// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать +// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую +// форму. +type IntakeItem struct { + RecordView + Duplicate bool `json:"duplicate"` +} + +type PageView struct { + Items []RecordView `json:"items"` + NextCursor *string `json:"next_cursor"` + TotalItems int `json:"total_items"` +} + +type MeView struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ConfigView struct { + MaxRecordSizeBytes int64 `json:"max_record_size_bytes"` + MaxPageSize int `json:"max_page_size"` + PollIntervalMs int64 `json:"poll_interval_ms"` + KnownExtensions []string `json:"known_extensions"` + MaxTopicsPerRecord int `json:"max_topics_per_record"` +} + +type TextView struct { + View string `json:"view"` + Contents string `json:"contents,omitempty"` + Replicas []ReplicaView `json:"replicas,omitempty"` +} + +type ReplicaView struct { + StartMs int64 `json:"start_ms"` + EndMs int64 `json:"end_ms"` + Text string `json:"text"` +} + +// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у +// панели один, поэтому и роутер один. +func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) { + app := r.Group(AppRoot) + + // Слой сессии вешается на **группу корня**, а не на перечень адресов: + // перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес + // молча перестал бы принимать куку. Собственная поверхность хранилища под + // слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам + // не шлёт. + // Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже — + // предел тела, ограничитель частоты, неизвестный путь под нашим корнем, — + // иначе ушли бы телом библиотеки, мимо единой формы. + app.Bind(OneErrorForm()) + app.Bind(SessionFromCookie()) + app.Bind(RequireUser(migrations.UsersCollection)) + + app.GET("/me", h.Me) + app.GET("/config", h.Config) + + // Приём стоит тем же адресом, что и список, и отличается только методом: он + // заводит аудиозапись, а не кладёт файл. + // + // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись + // раньше обработчика, без строки в журнале приёма. Предел тела равен потолку + // самой записи, а отказ по нему уходит нашей формой. + app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize)) + app.GET("/audiorecords", h.ListRecords) + app.GET("/audiorecords/{id}", h.GetRecord) + app.GET("/audiorecords/{id}/text", h.GetRecordText) + + // Перехват «под нашим корнем такого адреса нет». + // + // Слой единой формы его не покрывает, и это не оплошность приоритета: отказ + // «ничего не совпало» рождается маршрутом **корневой** группы, к которому + // слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный + // путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа + // под корнем приложения было бы две. + // + // Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии + // отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса + // приложения, и по той же причине: сперва «кто спрашивает», потом «что». + app.Any("/{path...}", func(e *core.RequestEvent) error { + return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден")) + }) +} + +func (h *AppHandler) Me(e *core.RequestEvent) error { + // Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит + // человеку, а не сервису. + return e.JSON(http.StatusOK, MeView{ + ID: e.Auth.Id, + Name: e.Auth.GetString("name"), + }) +} + +func (h *AppHandler) Config(e *core.RequestEvent) error { + // Каждый предел — то же значение, которое сервис применяет, а не его копия. + // Приложение, знающее предел своей константой, расходится с сервером молча — + // до первого отказа на записи, которую человек уже успел отправить. + return e.JSON(http.StatusOK, ConfigView{ + MaxRecordSizeBytes: entity.MaxRecordSize, + MaxPageSize: MaxPageLimit, + PollIntervalMs: PollIntervalMs, + KnownExtensions: metrics.PublicFormats(), + MaxTopicsPerRecord: entity.MaxTopicsPerRecord, + }) +} + +func (h *AppHandler) CreateRecord(e *core.RequestEvent) error { + file, header, err := e.Request.FormFile("audio") + if err != nil { + // Предел тела ловит объявленную длину заранее, слоем; необъявленную — + // на чтении, уже здесь. Не различив эти два отказа, приём сказал бы + // человеку «вы не приложили файл» о записи, которую он приложил и + // которая просто больше потолка. + if errors.Is(err, apis.ErrRequestEntityTooLarge) { + return fail(e, contract.ErrRecordTooLarge) + } + return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу")) + } + defer func() { + if err := file.Close(); err != nil { + h.logger.Error("Failed to close uploaded file", "error", err) + } + }() + + // Запись доехала целиком, поэтому она заводится независимо от того, дождётся + // ли отправитель ответа: на контексте запроса приём терял бы полностью + // загруженную запись от одного обрыва соединения, а забрать результат он + // может и позже — карточкой записи. Значения контекста (журнал запроса, + // сессия) при этом сохраняются, теряется только отмена. + ctx := context.WithoutCancel(e.Request.Context()) + + // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, + // пришедший полем запроса, дал бы всякому вошедшему право завести запись на + // чужое имя. + record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) + if err != nil { + // Второй раз отказ не логируем: приём назван конвенцией логирующей + // границей и уже написал о нём. Транспорт переводит ошибку в ответ, и + // делает это одним местом — по причине отказа, а не по месту. + return fail(e, err) + } + + // Ответ списком, даже когда файл в запросе один: форма согласована вперёд, + // чтобы приём нескольких файлов и распознавание повтора её не переписывали. + return e.JSON(http.StatusCreated, []IntakeItem{{ + // Свежая запись текстов не имеет, но поле обязано быть на проводе: + // отсутствие поля и пустой перечень приложение не различит. + RecordView: h.viewOf(record, nil, &[]string{}), + }}) +} + +func (h *AppHandler) ListRecords(e *core.RequestEvent) error { + q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit} + + if raw := e.Request.URL.Query().Get("limit"); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil || limit <= 0 { + return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом")) + } + // Сверх потолка — усечение, а не отказ: человек попросил больше, чем + // сервис отдаёт, но просьба сама по себе не негодна. + q.Limit = min(limit, MaxPageLimit) + } + + if raw := e.Request.URL.Query().Get("filter"); raw != "" { + filter, ok := entity.ParseListFilter(raw) + if !ok { + return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора")) + } + q.Filter = &filter + } + + if raw := e.Request.URL.Query().Get("cursor"); raw != "" { + cursor, err := decodeCursor(raw) + if err != nil { + // Молчаливая отдача первой страницы вместо отказа дала бы человеку + // архив, листающийся по кругу, и ни строки в журнале. + return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается")) + } + q.Cursor = cursor + } + + page, err := h.recordRepo.List(q) + if err != nil { + h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id) + return fail(e, err) + } + + names, err := h.topicNames(e.Auth.Id, page.Items) + if err != nil { + h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id) + return fail(e, err) + } + + view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems} + for _, record := range page.Items { + // Страница видов текста не читает: перечень доступных видов есть только у + // карточки, и опущенное поле честнее пустого — пустое читалось бы как + // «текста нет». + view.Items = append(view.Items, h.viewOf(record, names, nil)) + } + if page.NextCursor != nil { + encoded := encodeCursor(page.NextCursor) + view.NextCursor = &encoded + } + + return e.JSON(http.StatusOK, view) +} + +func (h *AppHandler) GetRecord(e *core.RequestEvent) error { + record, err := h.readOwn(e) + if err != nil { + return fail(e, err) + } + + names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record}) + if err != nil { + h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id) + return fail(e, err) + } + + views := h.availableViews(record) + return e.JSON(http.StatusOK, h.viewOf(record, names, &views)) +} + +func (h *AppHandler) GetRecordText(e *core.RequestEvent) error { + view := e.Request.URL.Query().Get("view") + if !entity.IsKnownTextView(view) { + return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста")) + } + + record, err := h.readOwn(e) + if err != nil { + return fail(e, err) + } + + if view == entity.TextViewReplicas { + return h.replicasOf(e, record) + } + return h.plainTextOf(e, record, view) +} + +// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают +// одним и тем же: по разнице ответов иначе перебирается список заведённых +// записей. +func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) { + recordID := e.Request.PathValue("id") + + record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) + if err != nil { + // Наружу ответ один на все исходы, а в журнал они идут по-разному. + // «Записи нет» и «запись чужая» — штатная работа разграничения, о ней + // писать нечего; всё прочее — отказ хранилища, и без этой строки он + // приходит отправителю как «вашей записи нет», а владелец сервиса об + // аварии не узнаёт ниоткуда. + var notFound *contract.JobNotFoundError + if !errors.As(err, ¬Found) { + h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID) + } + return nil, err + } + return record, nil +} + +func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error { + textID := record.TranscriptTextID + if view == entity.TextViewLiterary { + textID = record.LiteraryTextID + } + + if textID == nil { + return fail(e, contract.ErrTextNotReady) + } + + text, err := h.textRepo.GetByID(*textID) + if err != nil { + h.logger.Error("Failed to read text", "error", err, "record_id", record.Id) + return fail(e, err) + } + if text.Contents == "" { + return fail(e, contract.ErrTextNotReady) + } + + return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents}) +} + +func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error { + if record.StructureID == nil { + return fail(e, contract.ErrTextNotReady) + } + + structure, err := h.structureRepo.GetByID(*record.StructureID) + if err != nil { + h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id) + return fail(e, err) + } + if len(structure.Replicas) == 0 { + return fail(e, contract.ErrTextNotReady) + } + + replicas := make([]ReplicaView, 0, len(structure.Replicas)) + for _, r := range structure.Replicas { + replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text}) + } + + return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas}) +} + +// topicNames разрешает темы всех записей страницы **одним** запросом: страница в +// сотню записей иначе стоила бы сотни обращений к хранилищу. +func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) { + seen := map[string]bool{} + ids := []string{} + for _, record := range records { + for _, id := range record.TopicIDs { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + return h.recordRepo.ResolveTopicNames(ownerID, ids) +} + +func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView { + topics := make([]string, 0, len(record.TopicIDs)) + for _, id := range record.TopicIDs { + if name, ok := names[id]; ok { + topics = append(topics, name) + } + } + + return RecordView{ + ID: record.Id, + Title: record.Title, + OriginalFilename: record.OriginalFilename, + Brief: record.Brief, + Topics: topics, + State: record.State, + Halted: record.IsHalted(), + HaltReason: record.HaltReason, + DurationMs: record.DurationMs, + SizeBytes: record.SizeBytes, + CreatedAt: record.CreatedAt.Format(time.RFC3339), + AvailableViews: views, + } +} + +// availableViews — какие виды текста у записи есть **сейчас**. +// +// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет» +// достижимо, потому что шаг завершения пишет их несколькими операциями. +// +// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка +// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт +// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка +// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно: +// приложение опрашивало бы его без конца, а человек видел бы завершённую запись, +// из которой текст «вот-вот появится». +func (h *AppHandler) availableViews(record *entity.AudioRecord) []string { + views := []string{} + + if h.hasText(record.TranscriptTextID) { + views = append(views, entity.TextViewTranscript) + } + if h.hasText(record.LiteraryTextID) { + views = append(views, entity.TextViewLiterary) + } + if h.hasReplicas(record.StructureID) { + views = append(views, entity.TextViewReplicas) + } + return views +} + +// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается +// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить +// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса +// журналом, который пишет чтение текста. +func (h *AppHandler) hasText(textID *string) bool { + if textID == nil { + return false + } + text, err := h.textRepo.GetByID(*textID) + if err != nil { + h.logger.Error("Failed to read text while listing views", "error", err) + return false + } + return text.Contents != "" +} + +func (h *AppHandler) hasReplicas(structureID *string) bool { + if structureID == nil { + return false + } + structure, err := h.structureRepo.GetByID(*structureID) + if err != nil { + h.logger.Error("Failed to read structure while listing views", "error", err) + return false + } + return len(structure.Replicas) > 0 +} + +// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за +// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а +// составлять ключ руками значило бы завязаться на порядок сортировки. +// +// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара +// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите — +// ключ уезжает параметром, а не телом. +func encodeCursor(c *contract.RecordCursor) string { + return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID)) +} + +func decodeCursor(raw string) (*contract.RecordCursor, error) { + decoded, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("cursor is not decodable: %w", err) + } + + createdAt, id, ok := strings.Cut(string(decoded), "|") + if !ok || createdAt == "" || id == "" { + return nil, errors.New("malformed cursor") + } + + // Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым + // сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча + // обращает условие в постоянную истину или ложь — человек получает либо + // пустой архив при непустом счётчике, либо ленту с начала. + parsed, err := types.ParseDateTime(createdAt) + if err != nil || parsed.IsZero() { + return nil, errors.New("cursor carries no readable time") + } + + return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil +} + +// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у +// всех одна, а сказать человеку надо разное. +func errWithMessage(base error, message string) error { + return &messagedError{base: base, message: message} +} + +type messagedError struct { + base error + message string +} + +func (e *messagedError) Error() string { return e.message } +func (e *messagedError) Unwrap() error { return e.base } diff --git a/internal/controller/http/auth.go b/internal/controller/http/auth.go index e13b817..12e0744 100644 --- a/internal/controller/http/auth.go +++ b/internal/controller/http/auth.go @@ -246,6 +246,18 @@ func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (stri return "", fmt.Errorf("failed to build exchange request: %w", err) } request.Header.Set("Content-Type", "application/json") + // Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт. + // + // Ограничитель частоты хранилища ключует клиента адресом, а у собранного + // руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не + // пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем» + // такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись + // бы в один счётчик: третий вход в пределах трёх секунд — чей угодно — + // получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера. + // + // Прежде этого не случалось: ограничитель был выключен целиком. Он включается + // вместе с правилом под корнем приложения, и цена названа здесь. + request.RemoteAddr = "127.0.0.1:0" handler, err := h.storageHandler() if err != nil { diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go index aa98765..242ec62 100644 --- a/internal/controller/http/auth_test.go +++ b/internal/controller/http/auth_test.go @@ -44,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) { }) t.Run("опрос готовности без сессии", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/status/anything", nil) + req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil) w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) @@ -69,10 +69,10 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) { require.Len(t, jobs, 1) existing := httptest.NewRecorder() - env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/api/status/"+jobs[0].Id, nil)) + env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil)) missing := httptest.NewRecorder() - env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)) + env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)) assert.Equal(t, http.StatusUnauthorized, existing.Code) assert.Equal(t, missing.Code, existing.Code) @@ -123,7 +123,7 @@ func TestSessionSurvivesRestart(t *testing.T) { mux, err := r.BuildMux() require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil) + req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session}) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -164,7 +164,7 @@ func TestLogoutClosesAccess(t *testing.T) { // И прежнее значение больше не открывает доступ — этого уборка куки сама по // себе не даёт: унесённое значение работало бы до истечения срока. - after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil) + after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil) after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session}) afterResponse := httptest.NewRecorder() mux.ServeHTTP(afterResponse, after) @@ -206,7 +206,7 @@ func TestLogoutWhenAccountIsGone(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;") - after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil) + after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil) after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session}) afterResponse := httptest.NewRecorder() env.mux.ServeHTTP(afterResponse, after) @@ -345,7 +345,7 @@ func TestCallbackRejectsForeignState(t *testing.T) { func TestHeaderBeatsCookie(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil) + req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"}) req.Header.Set("Authorization", env.session) diff --git a/internal/controller/http/contract_test.go b/internal/controller/http/contract_test.go new file mode 100644 index 0000000..eda8019 --- /dev/null +++ b/internal/controller/http/contract_test.go @@ -0,0 +1,427 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/metrics" +) + +// Пределы объявляются тем же значением, которое сервис применяет, а не его +// копией. Приложение, знающее предел своей константой, расходится с сервером +// молча — до первого отказа на записи, которую человек уже успел отправить. +func TestConfig_LimitsAreTheAppliedOnes(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + var config ConfigView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config)) + + // Потолок размера — то же число, которым ограничено тело запроса приёма и + // которым сервис отвергает запись. + assert.Equal(t, entity.MaxRecordSize, config.MaxRecordSizeBytes) + assert.Equal(t, MaxPageLimit, config.MaxPageSize) + assert.Equal(t, entity.MaxTopicsPerRecord, config.MaxTopicsPerRecord) + assert.Equal(t, PollIntervalMs, config.PollIntervalMs) + + // Перечень расширений — тот же, что сужает метку метрики, за вычетом + // собственного умолчания сервиса: `audio` не формат, и подсказкой человеку + // выходить не должно. + assert.Equal(t, metrics.PublicFormats(), config.KnownExtensions) + assert.NotContains(t, config.KnownExtensions, "audio", + "умолчание сервиса форматом не является") + assert.Contains(t, config.KnownExtensions, "mp3") +} + +// Кто вошёл — приложение узнаёт ответом: кука недоступна скриптам страницы, и +// прочитать из неё имя оно не может вовсе. Адрес почты при этом наружу не идёт. +func TestMe_CarriesAccountWithoutEmail(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/me", http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + var me MeView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me)) + + assert.Equal(t, env.account.Id, me.ID) + assert.NotContains(t, w.Body.String(), "person@example.com", + "адрес почты принадлежит человеку, а не сервису") +} + +// Без сессии заведённая запись неотличима от неизвестной: иначе по разнице +// ответов перебирается список заведённых записей. +func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + existing := httptest.NewRecorder() + env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) + + unknown := httptest.NewRecorder() + env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody)) + + require.Equal(t, http.StatusUnauthorized, existing.Code) + require.Equal(t, http.StatusUnauthorized, unknown.Code) + assert.Equal(t, existing.Body.String(), unknown.Body.String(), + "тело одно: по разнице ответов иначе перебирается список записей") + + var body ErrorBody + require.NoError(t, json.Unmarshal(existing.Body.Bytes(), &body)) + assert.Equal(t, CodeUnauthorized, body.Code) +} + +// Имя файла отправителя доходит до своей колонки, а колонка заголовка остаётся +// пустой: приём заголовков не сочиняет, а посчитанное языковой моделью название +// легло бы поверх имени, если бы они делили одну колонку. +func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, createMultipartRequest(t, "разговор.mp3", []byte("данные"))) + require.Equal(t, http.StatusCreated, w.Code) + + item := intakeItemOf(t, w) + + record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) + require.NoError(t, err) + + require.NotNil(t, record.OriginalFilename) + assert.Equal(t, "разговор.mp3", *record.OriginalFilename) + assert.Nil(t, record.Title, "колонка заголовка у принятой записи пуста") + + // Длительность и размер — снимок принятого, взятый приёмом. + require.NotNil(t, record.DurationMs) + assert.Equal(t, int64(42_000), *record.DurationMs) + require.NotNil(t, record.SizeBytes) + assert.Positive(t, *record.SizeBytes) +} + +// Имя длиннее предела доходит до записи обрезанным. +// +// Управляющие знаки этой проверкой не судятся, и причина внешняя: имя с ними +// ломает разбор multipart раньше нашего кода — заголовок части становится +// негодным, и запрос до обработчика не доезжает вовсе. Уборку знаков поэтому +// судит проверка домена рядом, где живёт само правило. +func TestIntake_LongFilenameIsTrimmed(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + long := strings.Repeat("я", entity.MaxOriginalFilenameLen+50) + ".mp3" + + w := httptest.NewRecorder() + env.serve(w, createMultipartRequest(t, long, []byte("данные"))) + require.Equal(t, http.StatusCreated, w.Code) + + item := intakeItemOf(t, w) + + record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) + require.NoError(t, err) + require.NotNil(t, record.OriginalFilename) + + stored := *record.OriginalFilename + assert.Len(t, []rune(stored), entity.MaxOriginalFilenameLen, + "имя обрезано по пределу, и режется оно по знакам, а не по байтам") +} + +// Отказ по превышению потолка размера проходит через единую форму: прежде он +// уходил телом ограничителя тела и читался как «сломался сервер». +func TestIntake_TooLargeGoesThroughOneErrorForm(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + status, body := mapDomainError(errTooLargeForTest()) + + assert.Equal(t, http.StatusRequestEntityTooLarge, status) + assert.Equal(t, CodeTooLarge, body.Code) + require.NotNil(t, body.Limit, "предел уходит человеку числом") + assert.Equal(t, entity.MaxRecordSize, *body.Limit) + assert.NotEmpty(t, body.Message) + + // И тот же предел объявлен адресом пределов — одним числом, а не двумя. + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody)) + var config ConfigView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config)) + assert.Equal(t, *body.Limit, config.MaxRecordSizeBytes) +} + +// Форма тела отказа одна на всех ветвях: код разбирает программа, сообщение +// читает человек, сырого текста ошибки нет нигде. +func TestErrorBody_OneShapeAcrossBranches(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + cases := []struct { + name string + req func() *http.Request + code string + }{ + { + name: "записи нет", + req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) }, + code: CodeNotFound, + }, + { + name: "негодный ввод", + req: func() *http.Request { + return httptest.NewRequest("GET", "/app/audiorecords?limit=0", http.NoBody) + }, + code: CodeBadRequest, + }, + { + name: "негодная запись", + req: func() *http.Request { + return createMultipartRequestWithField(t, "wrong-field", "sample.mp3", []byte("данные")) + }, + code: CodeBadRequest, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + env.serve(w, tc.req()) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Equal(t, tc.code, body.Code, "код машиночитаем и из закрытого перечня") + assert.NotEmpty(t, body.Message, "рядом с кодом стоит фраза для человека") + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) + assert.Contains(t, raw, "error_code") + assert.Contains(t, raw, "message") + assert.NotContains(t, raw, "error", "прежнее поле ушло вместе с прежним контрактом") + }) + } +} + +// Своё правило ограничителя частоты заведено под корнем приложения: правило +// хранилища настроено на его собственный корень и наших адресов не покрывает. +func TestRateLimitRuleCoversAppRoot(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + require.NoError(t, ApplyAppRateLimit(env.app)) + + var found int + for _, rule := range env.app.Settings().RateLimits.Rules { + if strings.HasPrefix(rule.Label, AppRoot+"/") { + found++ + assert.Positive(t, rule.MaxRequests) + assert.Positive(t, rule.Duration) + } + } + assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно") + assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён") + + // Правило приводится к настройке **при каждом подъёме**, то есть на каждом + // рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу, + // и правила молча копились бы с каждой выкладкой. + require.NoError(t, ApplyAppRateLimit(env.app)) + require.NoError(t, ApplyAppRateLimit(env.app)) + + again := 0 + for _, rule := range env.app.Settings().RateLimits.Rules { + if strings.HasPrefix(rule.Label, AppRoot+"/") { + again++ + } + } + assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе") +} + +// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст +// в нём тоже: без этой проверки ветвь ни разу не исполнялась бы, а приложение не +// предложило бы открыть готовый текст. +func TestAvailableViewsCoverEveryKind(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + texts := pbrepo.NewTextRepository(env.app) + literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст") + require.NoError(t, err) + transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") + require.NoError(t, err) + + structures := pbrepo.NewStructureRepository(env.app) + structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}}) + require.NoError(t, err) + + record.LiteraryTextID = &literary.Id + record.TranscriptTextID = &transcript.Id + record.StructureID = &structure.Id + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + var card RecordView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) + + require.NotNil(t, card.AvailableViews, "поле обязано быть на проводе") + assert.ElementsMatch(t, + []string{entity.TextViewTranscript, entity.TextViewLiterary, entity.TextViewReplicas}, + *card.AvailableViews, + "каждый готовый вид назван перечнем") + + // И каждый названный вид действительно отдаётся своим адресом. + for _, view := range *card.AvailableViews { + got := textOf(t, env, record.Id, view) + assert.Equal(t, http.StatusOK, got.Code, "вид %q обещан перечнем и обязан отдаваться", view) + } +} + +// errTooLargeForTest — отказ по превышению потолка, каким его строит приём. +func errTooLargeForTest() error { + return contract.ErrRecordTooLarge +} + +// Отказ по превышению потолка размера проходит **настоящим путём**, а не вызовом +// отображателя. Прежде проверка звала `mapDomainError` самодельной ошибкой и была +// зелёной независимо от того, что происходит на проводе: предел тела срабатывает +// слоем, до обработчика запрос не доходит, и отказ уходил телом библиотеки. +func TestTooLargeOnTheRealPath(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + req := createMultipartRequest(t, "sample.mp3", []byte("данные")) + req.ContentLength = entity.MaxRecordSize + 1 + + w := httptest.NewRecorder() + env.serve(w, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeTooLarge, body.Code, "код машиночитаем") + require.NotNil(t, body.Limit, "предел уходит человеку числом") + assert.Equal(t, entity.MaxRecordSize, *body.Limit) + assert.Equal(t, 0, countJobs(t, env), "записи не заводится") +} + +// Отказ ограничителя частоты тоже идёт единой формой: он рождается слоем ниже +// обработчика, и без перевода приложение получило бы тело библиотеки на самом +// частом отказе после превышения размера. +func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + require.NoError(t, ApplyAppRateLimit(env.app)) + + var last *httptest.ResponseRecorder + for range appRateMaxRequests + 1 { + last = httptest.NewRecorder() + env.serve(last, httptest.NewRequest("GET", "/app/me", http.NoBody)) + if last.Code == http.StatusTooManyRequests { + break + } + } + + require.Equal(t, http.StatusTooManyRequests, last.Code, "ограничитель сработал") + + var body ErrorBody + require.NoError(t, json.Unmarshal(last.Body.Bytes(), &body)) + assert.Equal(t, CodeTooManyRequests, body.Code) + assert.NotEmpty(t, body.Message) +} + +// Объявленная частота опроса умещается в бюджет ограничителя с запасом: прежде +// она равнялась всему бюджету, и любой соседний запрос в ту же секунду выводил +// приложение за потолок — отказ, которого сервис сам же обещал избежать. +func TestPollIntervalLeavesBudgetHeadroom(t *testing.T) { + pollsPerWindow := int64(appRateWindowSec) * 1000 / PollIntervalMs + + assert.Less(t, pollsPerWindow, int64(appRateMaxRequests), + "опрос с объявленной частотой не выбирает бюджет целиком") + assert.Positive(t, pollsPerWindow, "и при этом опрашивать вообще можно") +} + +// Длинное расширение из имени отправителя не роняет приём в «внутреннюю ошибку»: +// хвост после последней точки задаёт отправитель, и без потолка имя `x.` с +// четырьмястами знаками валит заведение временного файла. +func TestIntake_AbsurdExtensionDoesNotBecomeInternalError(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, createMultipartRequest(t, "x."+strings.Repeat("a", 400), []byte("данные"))) + + require.Equal(t, http.StatusCreated, w.Code, + "запись принята: абсурдное расширение заменено собственным умолчанием") + assert.NotContains(t, env.journal.String(), "file name too long") +} + +// Неизвестный путь и неверный метод под корнем приложения тоже идут единой +// формой. Слой формы их не покрывает: отказ «ничего не совпало» рождается +// маршрутом корневой группы, к которому слои нашей группы не привязаны, — и без +// своего перехвата форм отказа под корнем было бы две. +func TestUnknownAddressUnderAppRootUsesOneErrorForm(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + cases := []struct { + name string + method string + path string + }{ + {name: "неизвестный путь", method: "GET", path: "/app/nosuchendpoint"}, + {name: "неверный метод у списка", method: "DELETE", path: "/app/audiorecords"}, + {name: "неверный метод у пределов", method: "POST", path: "/app/config"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest(tc.method, tc.path, http.NoBody)) + + require.Equal(t, http.StatusNotFound, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeNotFound, body.Code, "код машиночитаем, а не тело библиотеки") + assert.NotEmpty(t, body.Message) + }) + } +} + +// Ссылка на текст без содержимого видом не считается. Иначе карточка обещала бы +// вид, а адрес текста отвечал бы «ещё не готов» вечно: приложение опрашивало бы +// его без конца, а человек видел бы завершённую запись, из которой текст +// «вот-вот появится». Пустой ответ распознавания — состояние штатное. +func TestEmptyTextIsNotAnAvailableView(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + texts := pbrepo.NewTextRepository(env.app) + empty, err := texts.Put(record.Id, entity.TextKindTranscript, "") + require.NoError(t, err) + + record.TranscriptTextID = &empty.Id + record.MoveToState(entity.StateDone) + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + var card RecordView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) + + require.NotNil(t, card.AvailableViews) + assert.Empty(t, *card.AvailableViews, + "ссылка есть, содержимого нет — вид доступным не считается") + + // И адрес текста отвечает тем же: состоянием, а не обещанием. + assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code) +} diff --git a/internal/controller/http/errors.go b/internal/controller/http/errors.go new file mode 100644 index 0000000..88e6072 --- /dev/null +++ b/internal/controller/http/errors.go @@ -0,0 +1,235 @@ +package http + +import ( + "errors" + "net/http" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/router" + + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код +// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все +// три `400`, — а приложению надо решать, предлагать ли повтор и что показать +// человеку. Разбор русской фразы был бы единственным оставшимся путём. +const ( + CodeUnauthorized = "unauthorized" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeBadRequest = "bad_request" + CodeTooLarge = "too_large" + CodeNotReady = "not_ready" + CodeTooManyRequests = "too_many_requests" + CodeInternal = "internal" +) + +// ErrorBody — единая форма тела отказа на всех адресах приложения. +// +// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой +// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена +// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в +// журнале владельца сервиса. +// +// Limit заполняется только у отказа по размеру: экран обязан показать предел +// числом, а не пересказать его словами. +type ErrorBody struct { + Code string `json:"error_code"` + Message string `json:"message"` + Limit *int64 `json:"limit,omitempty"` +} + +// mapDomainError — **единственная** точка, где доменная ошибка становится кодом +// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик +// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя +// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а +// второе не говорило ему ничего. +// +// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится +// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в +// `internal`, и владелец сервиса видит в журнале аварию там, где её нет. +func mapDomainError(err error) (int, ErrorBody) { + switch { + case errors.Is(err, contract.ErrBadRequest): + // Причина у всех негодных вводов одна, а сказать человеку надо разное: + // «размер страницы отрицательный» и «неизвестный вид текста» ведут к + // разным действиям. Свой текст приезжает обёрткой; его нет — говорим + // общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы, + // а не библиотека. + message := "Запрос составлен неверно" + var owned *messagedError + if errors.As(err, &owned) { + message = owned.message + } + return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message} + + case errors.Is(err, contract.ErrRecordUnreadable): + return http.StatusBadRequest, ErrorBody{ + Code: CodeBadRequest, + Message: "Не удалось прочитать запись: формат не распознан или файл повреждён", + } + + case errors.Is(err, contract.ErrRecordTooLarge): + limit := entity.MaxRecordSize + return http.StatusRequestEntityTooLarge, ErrorBody{ + Code: CodeTooLarge, + Message: "Запись больше допустимого размера", + Limit: &limit, + } + + case errors.Is(err, contract.ErrTextNotReady): + return http.StatusConflict, ErrorBody{ + Code: CodeNotReady, + Message: "Текст этого вида для записи ещё не готов", + } + + case errors.Is(err, contract.ErrNotFound): + message := "Адрес не найден" + var owned *messagedError + if errors.As(err, &owned) { + message = owned.message + } + return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message} + + case errors.Is(err, contract.ErrUnauthorized): + return http.StatusUnauthorized, ErrorBody{ + Code: CodeUnauthorized, + Message: "Требуется вход", + } + + case errors.Is(err, contract.ErrOwnerRequired): + return http.StatusForbidden, ErrorBody{ + Code: CodeForbidden, + Message: "У вашей сессии нет учётной записи пользователя", + } + } + + // Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице + // ответов иначе перебирается список заведённых записей. + var notFound *contract.JobNotFoundError + if errors.As(err, ¬Found) { + return http.StatusNotFound, ErrorBody{ + Code: CodeNotFound, + Message: "Запись не найдена", + } + } + + return http.StatusInternalServerError, ErrorBody{ + Code: CodeInternal, + Message: "Внутренняя ошибка сервиса", + } +} + +// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ +// уходит наружу с адресов приложения. +func fail(e *core.RequestEvent, err error) error { + status, body := mapDomainError(err) + return e.JSON(status, body) +} + +// OneErrorForm переводит отказ библиотеки в нашу форму тела. +// +// Своей единой точки мало: часть отказов на адресах приложения рождается **не в +// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые: +// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим +// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого +// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной. +// +// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека +// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь +// «внутренняя ошибка» вместо предела числом. +// +// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже +// него, включая предел тела и ограничитель частоты. +func OneErrorForm() *hook.Handler[*core.RequestEvent] { + return &hook.Handler[*core.RequestEvent]{ + Id: "transcriberOneErrorForm", + Priority: apis.DefaultRateLimitMiddlewarePriority - 100, + Func: func(e *core.RequestEvent) error { + err := e.Next() + if err == nil { + return nil + } + + // Обработчик, ответивший через fail, ошибки не возвращает — его + // форма уже ушла в ответ, и сюда доходит только чужая. + var apiErr *router.ApiError + if !errors.As(err, &apiErr) { + return err + } + + status, translated := translateAPIError(apiErr) + return e.JSON(status, translated) + }, + } +} + +// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви +// названы поимённо: значение вне перечня приложению разбирать нечем. +func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) { + switch apiErr.Status { + case http.StatusRequestEntityTooLarge: + limit := entity.MaxRecordSize + return http.StatusRequestEntityTooLarge, ErrorBody{ + Code: CodeTooLarge, + Message: "Запись больше допустимого размера", + Limit: &limit, + } + case http.StatusTooManyRequests: + return http.StatusTooManyRequests, ErrorBody{ + Code: CodeTooManyRequests, + Message: "Слишком много запросов подряд, попробуйте позже", + } + case http.StatusNotFound: + return http.StatusNotFound, ErrorBody{ + Code: CodeNotFound, + Message: "Адрес не найден", + } + case http.StatusUnauthorized: + return mapDomainError(contract.ErrUnauthorized) + } + + return apiErr.Status, ErrorBody{ + Code: CodeInternal, + Message: "Внутренняя ошибка сервиса", + } +} + +// RequireUser — слой предъявления адресов приложения. +// +// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки +// уходит **её** формой тела, и на адресах приложения оказалось бы две формы +// отказа вместо одной. Проверка при этом та же самая, и коллекция названа +// поимённо: без имени пускается всякая учётная запись хранилища, включая +// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем +// записи он стать не может. +// +// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный +// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже +// уложенный файл — чего сервис не умеет вовсе. +func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] { + return &hook.Handler[*core.RequestEvent]{ + Id: "transcriberRequireUser", + // Сразу после слоя, который читает предъявленный токен: раньше него + // `e.Auth` ещё пуст, и всякий запрос получал бы отказ. + Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1, + Func: func(e *core.RequestEvent) error { + if e.Auth == nil { + return fail(e, contract.ErrUnauthorized) + } + + // Узнан он всё же узнан, а учётной записи пользователя у него нет: + // код здесь другой не по оплошности. `401` значит «предъяви себя», а + // предъявитель себя предъявил. + if e.Auth.Collection().Name != usersCollection { + return fail(e, contract.ErrOwnerRequired) + } + + return e.Next() + }, + } +} diff --git a/internal/controller/http/list_test.go b/internal/controller/http/list_test.go new file mode 100644 index 0000000..588ef35 --- /dev/null +++ b/internal/controller/http/list_test.go @@ -0,0 +1,436 @@ +package http + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// pageOf спрашивает страницу записей от имени вошедшего. +func pageOf(t *testing.T, env *testEnv, query string) PageView { + t.Helper() + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords"+query, http.NoBody)) + require.Equal(t, http.StatusOK, w.Code, "тело: %s", w.Body.String()) + + var page PageView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page)) + return page +} + +// acceptRecords заводит несколько записей приёмом — тем же путём, каким они +// появляются в проде. +func acceptRecords(t *testing.T, env *testEnv, n int) { + t.Helper() + + for i := range n { + w := httptest.NewRecorder() + env.serve(w, createMultipartRequest(t, fmt.Sprintf("запись-%d.mp3", i), []byte("данные"))) + require.Equal(t, http.StatusCreated, w.Code) + } +} + +// Страница отдаётся новыми сверху и несёт общее число записей. +func TestList_NewestFirst(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 3) + + page := pageOf(t, env, "?limit=2") + + require.Len(t, page.Items, 2) + assert.Equal(t, 3, page.TotalItems, "общее число не зависит от размера страницы") + assert.Equal(t, "запись-2.mp3", *page.Items[0].OriginalFilename, "первой стоит заведённая последней") + require.NotNil(t, page.NextCursor, "есть что читать дальше") +} + +// Запись, заведённая между двумя страницами, окна не сдвигает: ключ задаёт +// положение, а не смещение. Со смещением один элемент пришёл бы дважды, а другой +// не пришёл бы никогда — и оба раза молча. +func TestList_RecordAcceptedBetweenPagesDoesNotShiftWindow(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 4) + + first := pageOf(t, env, "?limit=2") + require.Len(t, first.Items, 2) + require.NotNil(t, first.NextCursor) + + // Человек загружает ещё одну запись, не закрыв список, — штатный сценарий + // экрана загрузки. + acceptRecords(t, env, 1) + + second := pageOf(t, env, "?limit=2&cursor="+*first.NextCursor) + + seen := map[string]bool{} + for _, item := range first.Items { + seen[item.ID] = true + } + for _, item := range second.Items { + assert.False(t, seen[item.ID], "элемент первой страницы не приходит вторым разом") + seen[item.ID] = true + } + + // Ни одна из четырёх исходных записей не потеряна: дочитываем до конца. + cursor := second.NextCursor + for cursor != nil { + page := pageOf(t, env, "?limit=2&cursor="+*cursor) + for _, item := range page.Items { + seen[item.ID] = true + } + cursor = page.NextCursor + } + assert.Len(t, seen, 4, "все четыре исходные записи дочитаны, ни одна не пропущена") + + // Пятая, заведённая уже после начала листания, стоит **выше** окна и потому + // движением вперёд не приходит — это и есть искомое свойство ключа. Человек + // видит её, перечитав первую страницу. + fresh := pageOf(t, env, "?limit=2") + assert.Equal(t, 5, fresh.TotalItems) + assert.Equal(t, "запись-0.mp3", *fresh.Items[0].OriginalFilename, + "свежая запись видна сверху при перечитывании") +} + +// Записи с одинаковым временем заведения идут в устойчивом порядке: ключ +// сортировки полный, а одного времени мало — у записей, принятых одним запросом, +// оно совпадает. +func TestList_EqualCreatedAtKeepsStableOrder(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 4) + + first := pageOf(t, env, "") + second := pageOf(t, env, "") + + require.Len(t, first.Items, 4) + for i := range first.Items { + assert.Equal(t, first.Items[i].ID, second.Items[i].ID, + "порядок не меняется от прогона к прогону") + } +} + +// Размер страницы сверх потолка усекается, а негодный отвергается: человек +// попросил больше, чем сервис отдаёт, но просьба сама по себе не негодна. +func TestList_PageSizeCeilingAndBadValue(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 2) + + page := pageOf(t, env, fmt.Sprintf("?limit=%d", MaxPageLimit+500)) + assert.LessOrEqual(t, len(page.Items), MaxPageLimit) + + for _, bad := range []string{"0", "-3", "много"} { + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?limit="+bad, http.NoBody)) + assert.Equal(t, http.StatusBadRequest, w.Code, "размер %q негоден", bad) + } +} + +// Ключ, который сервис не может прочитать, даёт отказ, а не первую страницу: +// молчаливая отдача первой дала бы человеку архив, листающийся по кругу. +// +// Негодность у ключа двух родов, и обе ветви разбора судятся здесь: строка, +// которая не декодируется вовсе, и строка, которая декодируется — то есть +// подделывается легко, — но не несёт пары «время и идентификатор». +func TestList_MalformedCursorIsRejected(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 1) + + cases := []struct { + name string + cursor string + }{ + { + name: "не декодируется вовсе", + cursor: "мусор", + }, + { + name: "декодируется, но разделителя нет", + cursor: base64.RawURLEncoding.EncodeToString([]byte("без-разделителя")), + }, + { + name: "декодируется, но времени нет", + cursor: base64.RawURLEncoding.EncodeToString([]byte("|только-идентификатор")), + }, + { + name: "декодируется, но идентификатора нет", + cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")), + }, + { + name: "пара пуста целиком", + cursor: base64.RawURLEncoding.EncodeToString([]byte("|")), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?cursor="+tc.cursor, http.NoBody)) + require.Equal(t, http.StatusBadRequest, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeBadRequest, body.Code) + }) + } +} + +// Темы разрешаются названиями — и в странице, и в карточке. Ни приём, ни +// конвейер их сегодня не пишут, поэтому без этой проверки весь путь разрешения +// впервые исполнился бы в бою, у первого же человека со связанной темой. +func TestTopicsResolveToNames(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 1) + + all := pageOf(t, env, "") + require.Len(t, all.Items, 1) + + // Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его + // задача языковой модели, которой ещё нет. + topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) + require.NoError(t, err) + topic := core.NewRecord(topics) + topic.Set("owner", env.account.Id) + topic.Set("name", "семейный архив") + require.NoError(t, env.app.Save(topic)) + + repo := pbrepo.NewAudioRecordRepository(env.app) + record, err := repo.GetByID(all.Items[0].ID, env.account.Id) + require.NoError(t, err) + record.TopicIDs = []string{topic.Id} + require.NoError(t, repo.Save(record, "")) + + // Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это + // сделает задача языковой модели, — прямым сохранением записи коллекции. + raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) + require.NoError(t, err) + raw.Set("topics", []string{topic.Id}) + require.NoError(t, env.app.Save(raw)) + + page := pageOf(t, env, "") + require.Len(t, page.Items, 1) + assert.Equal(t, []string{"семейный архив"}, page.Items[0].Topics, + "страница отдаёт название темы, а не её идентификатор") + + card := httptest.NewRecorder() + env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) + require.Equal(t, http.StatusOK, card.Code) + + var view RecordView + require.NoError(t, json.Unmarshal(card.Body.Bytes(), &view)) + assert.Equal(t, []string{"семейный архив"}, view.Topics) +} + +// Отбор различает три состояния, и остановленная запись приходит ровно в одном +// из них. Надвое она выпала бы из обеих половин — исчезла бы из списка при любом +// значении, хотя ради неё список и открывают. +func TestList_ThreeStatesEachRecordOnce(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 3) + + all := pageOf(t, env, "") + require.Len(t, all.Items, 3) + + repo := pbrepo.NewAudioRecordRepository(env.app) + + halted, err := repo.GetByID(all.Items[0].ID, env.account.Id) + require.NoError(t, err) + halted.Halt(entity.HaltReasonStuck, "застряла") + require.NoError(t, repo.Save(halted, "")) + + done, err := repo.GetByID(all.Items[1].ID, env.account.Id) + require.NoError(t, err) + done.MoveToState(entity.StateDone) + require.NoError(t, repo.Save(done, "")) + + counts := map[string]int{} + for _, filter := range []string{"working", "halted", "done"} { + page := pageOf(t, env, "?filter="+filter) + for _, item := range page.Items { + counts[item.ID]++ + } + } + + require.Len(t, counts, 3, "все три записи видны отбором") + for id, seen := range counts { + assert.Equal(t, 1, seen, "запись %s приходит ровно в одном состоянии", id) + } + + // И остановленная приходит с причиной: без неё признак не говорит человеку, + // чего ждать. + haltedPage := pageOf(t, env, "?filter=halted") + require.Len(t, haltedPage.Items, 1) + require.NotNil(t, haltedPage.Items[0].HaltReason) + assert.Equal(t, entity.HaltReasonStuck, *haltedPage.Items[0].HaltReason) +} + +// Неизвестное состояние отбора — негодный ввод, а не пустая выборка. +func TestList_UnknownFilterIsRejected(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?filter=неизвестно", http.NoBody)) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// Чужих записей в странице нет. +func TestList_ShowsOnlyOwnRecords(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 2) + + _, stranger := newSecondAccount(t, env.app) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody) + req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: stranger}) + env.mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var page PageView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page)) + assert.Empty(t, page.Items, "чужие записи в страницу не попадают") + assert.Equal(t, 0, page.TotalItems) +} + +// Список не тянет расшифровку: она лежит порознь от записи ровно затем, чтобы +// чтение страницы её не читало. +func TestList_DoesNotReadTranscript(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 1) + + all := pageOf(t, env, "") + require.Len(t, all.Items, 1) + + repo := pbrepo.NewAudioRecordRepository(env.app) + record, err := repo.GetByID(all.Items[0].ID, env.account.Id) + require.NoError(t, err) + + const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР" + texts := pbrepo.NewTextRepository(env.app) + transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker) + require.NoError(t, err) + record.TranscriptTextID = &transcript.Id + require.NoError(t, repo.Save(record, "")) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + assert.NotContains(t, w.Body.String(), marker, + "текст расшифровки в страницу не попадает") +} + +// Карточка и элемент страницы — одна форма: две формы одной вещи разошлись бы +// молча, и экран, написанный по одной, ломался бы о другую. +func TestCardAndPageItemShareOneShape(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + w := httptest.NewRecorder() + env.serve(w, createMultipartRequest(t, "запись.mp3", []byte("данные"))) + require.Equal(t, http.StatusCreated, w.Code) + + var intake []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &intake)) + require.Len(t, intake, 1) + + item := httptest.NewRecorder() + env.serve(item, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) + var rawPage struct { + Items []map[string]json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal(item.Body.Bytes(), &rawPage)) + require.Len(t, rawPage.Items, 1) + + id := strings.Trim(string(intake[0]["id"]), `"`) + card := httptest.NewRecorder() + env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+id, http.NoBody)) + var rawCard map[string]json.RawMessage + require.NoError(t, json.Unmarshal(card.Body.Bytes(), &rawCard)) + + // Карточка = элемент страницы плюс перечень доступных видов. + assert.Equal(t, fieldNames(rawPage.Items[0]), fieldNames(rawCard, "available_views"), + "карточка отличается от элемента страницы ровно перечнем видов") + + // Элемент ответа приёма = карточка плюс признак повтора. Перечень видов есть + // у обоих: у свежей записи он пуст, но на проводе присутствует — отсутствие + // поля и пустой перечень приложение не различит. + assert.Equal(t, fieldNames(rawCard), fieldNames(intake[0], "duplicate"), + "элемент ответа приёма отличается от карточки ровно признаком повтора") + assert.Contains(t, intake[0], "available_views", + "перечень видов есть и в ответе приёма, пустым") +} + +// fieldNames отдаёт отсортированные имена полей за вычетом названных. +func fieldNames(raw map[string]json.RawMessage, except ...string) []string { + skip := map[string]bool{} + for _, name := range except { + skip[name] = true + } + + out := []string{} + for name := range raw { + if !skip[name] { + out = append(out, name) + } + } + sortStrings(out) + return out +} + +func sortStrings(v []string) { + for i := 1; i < len(v); i++ { + for j := i; j > 0 && v[j] < v[j-1]; j-- { + v[j], v[j-1] = v[j-1], v[j] + } + } +} + +// Разрешение тем сужено владельцем: словарь тем свой у каждого человека — пара +// «владелец и название» уникальна, — и без сужения название чужой темы приехало +// бы в ответ, как только темы начнёт писать языковая модель. +func TestForeignTopicDoesNotResolve(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + acceptRecords(t, env, 1) + all := pageOf(t, env, "") + require.Len(t, all.Items, 1) + + stranger, _ := newSecondAccount(t, env.app) + + topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) + require.NoError(t, err) + foreign := core.NewRecord(topics) + foreign.Set("owner", stranger.Id) + foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР") + require.NoError(t, env.app.Save(foreign)) + + raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID) + require.NoError(t, err) + raw.Set("topics", []string{foreign.Id}) + require.NoError(t, env.app.Save(raw)) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) + require.Equal(t, http.StatusOK, w.Code) + + assert.NotContains(t, w.Body.String(), "ЧУЖАЯ-ТЕМА-МАРКЕР", + "название чужой темы наружу не выходит") +} diff --git a/internal/controller/http/login_test.go b/internal/controller/http/login_test.go index 460d7a8..5398067 100644 --- a/internal/controller/http/login_test.go +++ b/internal/controller/http/login_test.go @@ -182,13 +182,18 @@ func TestLoginCreatesAccountAndSession(t *testing.T) { assert.True(t, cleared, "носитель состояния пережил возврат") // Выданная сессия открывает доступ к закрытым адресам. - check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil) + check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil) check.AddCookie(session) checkResponse := httptest.NewRecorder() r, err := apis.NewRouter(env.app) require.NoError(t, err) - NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r) + NewAppHandler( + pbrepo.NewAudioRecordRepository(env.app), + pbrepo.NewTextRepository(env.app), + pbrepo.NewStructureRepository(env.app), + nil, nil, + ).Register(r) checkMux, err := r.BuildMux() require.NoError(t, err) checkMux.ServeHTTP(checkResponse, check) diff --git a/internal/controller/http/ownership_test.go b/internal/controller/http/ownership_test.go index f40d37f..7bb0df9 100644 --- a/internal/controller/http/ownership_test.go +++ b/internal/controller/http/ownership_test.go @@ -63,10 +63,10 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { _, stranger := newSecondAccount(t, env.app) foreign := httptest.NewRecorder() - serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)) + serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)) unknown := httptest.NewRecorder() - serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/api/status/unknown0000000000", http.NoBody)) + serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody)) require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора") @@ -86,10 +86,9 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись"))) require.Equal(t, http.StatusCreated, w.Code) - var response CreateTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + response := intakeItemOf(t, w) - record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") @@ -113,10 +112,9 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) - var response CreateTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + response := intakeItemOf(t, w) - record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner")) } diff --git a/internal/controller/http/rate_limit.go b/internal/controller/http/rate_limit.go new file mode 100644 index 0000000..30fd4f3 --- /dev/null +++ b/internal/controller/http/rate_limit.go @@ -0,0 +1,58 @@ +package http + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/core" +) + +// Своё правило ограничителя частоты под корнем приложения. +// +// Заводится потому, что правило хранилища настроено на **его** корень и наших +// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с +// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая — +// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле. +// +// Числа скромные намеренно: сервисом пользуются единицы человек, а экран +// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится +// частота опроса, которую сервис объявляет приложению, — два числа об одном и +// том же разъехались бы при первой правке. +const ( + appRateMaxRequests = 120 + appRateWindowSec = 60 +) + +// ApplyAppRateLimit ставит правило ограничителя на корень приложения. +// +// Правило приводится к настройке при каждом подъёме, как и настройки провайдера: +// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и +// правило, положенное однажды, не пережило бы ни правки числа, ни чистого +// каталога данных. +func ApplyAppRateLimit(app core.App) error { + settings := app.Settings() + + rule := core.RateLimitRule{ + Label: AppRoot + "/", + MaxRequests: appRateMaxRequests, + Duration: appRateWindowSec, + } + + replaced := false + for i, existing := range settings.RateLimits.Rules { + if existing.Label == rule.Label { + settings.RateLimits.Rules[i] = rule + replaced = true + break + } + } + if !replaced { + settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule) + } + + settings.RateLimits.Enabled = true + + if err := app.Save(settings); err != nil { + return fmt.Errorf("failed to apply app rate limit: %w", err) + } + return nil +} diff --git a/internal/controller/http/status_test.go b/internal/controller/http/status_test.go index b0f8e66..e00b2a4 100644 --- a/internal/controller/http/status_test.go +++ b/internal/controller/http/status_test.go @@ -17,22 +17,105 @@ import ( "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его -// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста. +// Карточка записи и её текст читаются порознь: шестичасовая расшифровка, +// приехавшая вместе с шапкой, задерживает показ на мобильной сети на время, +// которое человеку не нужно ждать. -// statusOf спрашивает состояние записи от имени её владельца. -func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder { +// cardOf спрашивает карточку записи от имени её владельца. +func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() - env.serve(w, httptest.NewRequest("GET", "/api/status/"+recordID, http.NoBody)) + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody)) return w } -// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и -// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача -// «последнего записанного» сделала бы ответ функцией порядка записи. -func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) { +// textOf спрашивает текст записи названного вида. +func textOf(t *testing.T, env *testEnv, recordID, view string) *httptest.ResponseRecorder { + t.Helper() + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID+"/text?view="+view, http.NoBody)) + return w +} + +// Карточка текста не несёт вовсе, а о его наличии сообщает перечнем доступных +// видов. Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет» +// достижимо, и один признак отправил бы приложение за репликами, которых нет. +func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + texts := pbrepo.NewTextRepository(env.app) + transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") + require.NoError(t, err) + + record.TranscriptTextID = &transcript.Id + record.MoveToState(entity.StateDone) + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + w := cardOf(t, env, record.Id) + require.Equal(t, http.StatusOK, w.Code) + + var card RecordView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) + + assert.Equal(t, entity.StateDone, card.State) + assert.NotContains(t, w.Body.String(), "сырая расшифровка", + "текст в карточку не кладётся: за ним идут отдельным адресом") + require.NotNil(t, card.AvailableViews) + assert.Equal(t, []string{entity.TextViewTranscript}, *card.AvailableViews) +} + +// Пока запись не дошла до текста, перечень доступных видов пуст. Пустой перечень +// значит «текста ещё нет» — и это состояние, а не отсутствие записи. +func TestRecordCard_NoTextYetGivesEmptyViews(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + w := cardOf(t, env, record.Id) + require.Equal(t, http.StatusOK, w.Code) + + var card RecordView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) + + assert.Equal(t, entity.StateUploaded, card.State) + assert.False(t, card.Halted, "запись в работе остановленной не значится") + require.NotNil(t, card.AvailableViews, "поле есть на проводе даже когда текста нет") + assert.Empty(t, *card.AvailableViews) +} + +// Остановленная запись отдаёт рубеж, на котором встала, признак остановки и её +// причину. Этим держится инвариант проекта: опрос готовности убран, и карточка — +// единственное место, где отправитель узнаёт о неудаче. +func TestRecordCard_HaltedCarriesReason(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + record.MoveToState(entity.StateNormalized) + record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла") + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + w := cardOf(t, env, record.Id) + require.Equal(t, http.StatusOK, w.Code) + + var card RecordView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) + + assert.Equal(t, entity.StateNormalized, card.State, "рубеж тот, на котором запись встала") + assert.True(t, card.Halted, "признак остановки виден владельцу записи") + require.NotNil(t, card.HaltReason, "без причины признак не говорит, чего ждать") + assert.Equal(t, entity.HaltReasonStepFailed, *card.HaltReason) + assert.NotContains(t, w.Body.String(), "сбой конвертации файла", + "машинный текст отказа принадлежит журналу владельца сервиса, а не ответу") +} + +// Сырая расшифровка отдаётся своим видом, и только она: видов текста больше +// одного, и отдача «последнего записанного» сделала бы ответ функцией порядка +// записи, а не состояния записи. +func TestRecordText_TranscriptView(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) @@ -45,61 +128,95 @@ func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) { record.TranscriptTextID = &transcript.Id record.LiteraryTextID = &literary.Id - record.MoveToState(entity.StateDone) require.NoError(t, env.handler.recordRepo.Save(record, "")) - w := statusOf(t, env, record.Id) + w := textOf(t, env, record.Id, entity.TextViewTranscript) require.Equal(t, http.StatusOK, w.Code) - var response GetTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + var text TextView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text)) - assert.Equal(t, entity.StateDone, response.State) - require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя") - assert.Equal(t, "сырая расшифровка", *response.TranscriptionText) + assert.Equal(t, entity.TextViewTranscript, text.View) + assert.Equal(t, "сырая расшифровка", text.Contents) assert.NotContains(t, w.Body.String(), "вычитанный текст", - "вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг") + "вычитанный текст этим видом не подменяется: у него своё значение перечня") } -// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки -// отдельным полем: отказ перестал быть состоянием, и без признака такая запись -// выглядела бы обычной, стоящей на своём рубеже. -func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) { +// Реплики со временем — своё значение перечня, а не форма показа расшифровки: +// они лежат структурой разбора и принадлежат записи, а не тексту. +func TestRecordText_ReplicasView(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) - record.MoveToState(entity.StateNormalized) - record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла") + + structures := pbrepo.NewStructureRepository(env.app) + structure, err := structures.Put(record.Id, 1, []entity.Replica{ + {StartMs: 0, EndMs: 1500, Text: "первая реплика"}, + {StartMs: 1500, EndMs: 3000, Text: "вторая реплика"}, + }) + require.NoError(t, err) + + record.StructureID = &structure.Id require.NoError(t, env.handler.recordRepo.Save(record, "")) - w := statusOf(t, env, record.Id) + w := textOf(t, env, record.Id, entity.TextViewReplicas) require.Equal(t, http.StatusOK, w.Code) - var response GetTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + var text TextView + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text)) - assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала") - assert.True(t, response.Halted, "признак остановки виден отправителю") - assert.NotContains(t, w.Body.String(), "сбой конвертации файла", - "машинный текст отказа принадлежит журналу владельца, а не ответу отправителю") + assert.Equal(t, entity.TextViewReplicas, text.View) + require.Len(t, text.Replicas, 2) + assert.Equal(t, "первая реплика", text.Replicas[0].Text) + assert.Equal(t, int64(1500), text.Replicas[0].EndMs, "у каждой реплики стоит её время") } -// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте -// читается как «расшифровка пуста». -func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) { +// «Текста этого вида ещё нет» обязано отличаться от «записи нет»: иначе человек +// увидел бы «не найдено» на своей записи, загруженной минуту назад, — ровно тот +// отказ, ради устранения которого заведён весь контракт. +func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) - w := statusOf(t, env, record.Id) - require.Equal(t, http.StatusOK, w.Code) + texts := pbrepo.NewTextRepository(env.app) + transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") + require.NoError(t, err) + record.TranscriptTextID = &transcript.Id + require.NoError(t, env.handler.recordRepo.Save(record, "")) - var response GetTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + // Расшифровка есть, структуры реплик нет — состояние достижимое: шаг + // завершения пишет их несколькими операциями. + notReady := textOf(t, env, record.Id, entity.TextViewReplicas) + assert.Equal(t, http.StatusConflict, notReady.Code) - assert.Equal(t, entity.StateUploaded, response.State) - assert.False(t, response.Halted, "запись в работе остановленной не значится") - assert.Nil(t, response.TranscriptionText) + var body ErrorBody + require.NoError(t, json.Unmarshal(notReady.Body.Bytes(), &body)) + assert.Equal(t, CodeNotReady, body.Code) + + missing := textOf(t, env, "nosuchrecordid", entity.TextViewTranscript) + assert.Equal(t, http.StatusNotFound, missing.Code) + + var missingBody ErrorBody + require.NoError(t, json.Unmarshal(missing.Body.Bytes(), &missingBody)) + assert.Equal(t, CodeNotFound, missingBody.Code) + assert.NotEqual(t, body.Code, missingBody.Code, + "«ещё не готово» и «записи нет» ведут к разным действиям человека") +} + +// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу: +// умолчание сделало бы ответ функцией того, что успел записать конвейер. +func TestRecordText_UnknownViewIsBadRequest(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + unknown := textOf(t, env, record.Id, "unknown-view") + assert.Equal(t, http.StatusBadRequest, unknown.Code) + + w := httptest.NewRecorder() + env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text", http.NoBody)) + assert.Equal(t, http.StatusBadRequest, w.Code, "незаданный вид известным не считается") } // failingTextRepo отказывает на чтении текста — так выглядит недоступное @@ -119,7 +236,7 @@ func (r *failingTextRepo) GetByID(string) (*entity.Text, error) { // приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе // она читалась бы отправителю как «вашей записи не существует», а владельцем не // замечалась бы вовсе. -func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) { +func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) @@ -134,9 +251,10 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) { // Обработчик пересобирается с отказывающим хранилищем текстов: остальная // цепочка та же, что и в проде. journal := &journalBuffer{} - handler := NewTranscribeHandler( + handler := NewAppHandler( env.handler.recordRepo, &failingTextRepo{}, + pbrepo.NewStructureRepository(env.app), env.handler.trsService, slog.New(slog.NewTextHandler(journal, nil)), ) @@ -147,7 +265,7 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) { mux, err := r.BuildMux() require.NoError(t, err) - req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody) + req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session}) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -155,6 +273,22 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code, "отказ хранилища не выдаётся за отсутствие записи") assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят") - assert.Contains(t, journal.String(), "Failed to read transcript", + assert.Contains(t, journal.String(), "Failed to read text", "владелец сервиса узнаёт об аварии из журнала") } + +// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и +// адрес, отвечающий по-старому, означал бы два дома у одного вопроса. +func TestFormerAddressesAreGone(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + record := jobWithFile(t, env) + + status := httptest.NewRecorder() + env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)) + assert.Equal(t, http.StatusNotFound, status.Code) + + intake := httptest.NewRecorder() + env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные"))) + assert.Equal(t, http.StatusNotFound, intake.Code) +} diff --git a/internal/controller/http/transcribe.go b/internal/controller/http/transcribe.go deleted file mode 100644 index 3ef3d65..0000000 --- a/internal/controller/http/transcribe.go +++ /dev/null @@ -1,169 +0,0 @@ -package http - -import ( - "context" - "errors" - "log/slog" - "net/http" - "time" - - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/router" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/entity" - "git.vakhrushev.me/av/transcriber/internal/service" -) - -type TranscribeHandler struct { - recordRepo contract.AudioRecordRepository - textRepo contract.TextRepository - trsService *service.TranscribeService - logger *slog.Logger -} - -func NewTranscribeHandler( - recordRepo contract.AudioRecordRepository, - textRepo contract.TextRepository, - trsService *service.TranscribeService, - logger *slog.Logger, -) *TranscribeHandler { - if logger == nil { - logger = slog.Default() - } - return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger} -} - -type CreateTranscribeJobResponse struct { - JobID string `json:"job_id"` - State string `json:"status"` -} - -// GetTranscribeJobResponse — ответ об одной записи. -// -// Имена полей нормативны и остались прежними: контракт HTTP API объявлен -// проектом необратимым, и переименование поля ломает внешнюю программу молча. -// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и -// это объявленная ломка. -// -// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная -// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст -// отказа в ответ не идёт: он принадлежит журналу владельца сервиса. -type GetTranscribeJobResponse struct { - JobID string `json:"job_id"` - State string `json:"status"` - Halted bool `json:"halted"` - CreatedAt time.Time `json:"created_at"` - TranscriptionText *string `json:"transcription_text,omitempty"` -} - -// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у -// панели один, поэтому и роутер один; имена полей ответа и коды при переезде -// сохранены — публичный контракт HTTP API объявлен необратимым. -func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { - api := r.Group("/api") - - // Оба адреса уходят за аутентификацию. Слой предъявления стоит перед - // проверкой и действует только здесь: собственная поверхность хранилища под - // него не подпадает, часть её защищена ровно тем, что браузер заголовка сам - // не шлёт. - api.Bind(SessionFromCookie()) - // Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка - // пускает всякую учётную запись хранилища, включая владельца панели, — а - // записи в коллекции пользователей у него нет, и владельцем записи он стать - // не может. Отказ такому предъявителю обязан наступить здесь, до чтения - // тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов - // сервис не умеет вовсе. - api.Bind(apis.RequireAuth(migrations.UsersCollection)) - - // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись - // раньше обработчика, без строки в журнале приёма. Приём размеру не судья, - // поэтому предел тела равен потолку самой записи. - api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize)) - api.GET("/status/{id}", h.GetTranscribeJobStatus) -} - -func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error { - // Получаем файл из формы - file, header, err := e.Request.FormFile("audio") - if err != nil { - return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"}) - } - defer func() { - if err := file.Close(); err != nil { - h.logger.Error("Failed to close uploaded file", "error", err) - } - }() - - // Запись доехала целиком, поэтому она заводится независимо от того, дождётся - // ли отправитель ответа: на контексте запроса приём терял бы полностью - // загруженную запись от одного обрыва соединения, а забрать результат он - // может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса, - // сессия) при этом сохраняются, теряется только отмена. - ctx := context.WithoutCancel(e.Request.Context()) - - // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, - // пришедший полем запроса, дал бы всякому вошедшему право завести запись на - // чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth` - // уже есть и принадлежит коллекции пользователей. - record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) - if err != nil { - // Второй раз отказ не логируем: приём назван конвенцией логирующей - // границей и уже написал о нём. Транспорт переводит ошибку в ответ. - return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"}) - } - - // Возвращаем успешный ответ - return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{ - JobID: record.Id, - State: record.State, - }) -} - -func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error { - recordID := e.Request.PathValue("id") - - // Чужая запись, запись без владельца и несуществующая отвечают одним и тем - // же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и - // то же тело. Различать их наружу нельзя — по разнице ответов перебирается - // список заведённых записей. - record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) - if err != nil { - // Наружу ответ один на все исходы, а в журнал они идут по-разному. - // «Записи нет» и «запись чужая» — штатная работа разграничения, о ней - // писать нечего; всё прочее — отказ хранилища, и без этой строки он - // приходит отправителю как «вашей записи нет», а владелец сервиса об - // аварии не узнаёт ниоткуда. - var notFound *contract.JobNotFoundError - if !errors.As(err, ¬Found) { - h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID) - } - return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"}) - } - - response := GetTranscribeJobResponse{ - JobID: record.Id, - State: record.State, - Halted: record.IsHalted(), - CreatedAt: record.CreatedAt, - } - - // Вид текста называется **явно**: видов у записи больше одного, и отдача - // «последнего записанного» сделала бы ответ функцией порядка записи, а не - // состояния записи. В это поле уходит сырая расшифровка, и только она. - if record.TranscriptTextID != nil { - text, err := h.textRepo.GetByID(*record.TranscriptTextID) - if err != nil { - h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID) - return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"}) - } - if text.Contents != "" { - contents := text.Contents - response.TranscriptionText = &contents - } - } - - return e.JSON(http.StatusOK, response) -} diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 0de4e35..fdfc446 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -69,7 +69,7 @@ func readableMetaViewer() *stubMetaViewer { // рабочий каталог процесса проверки не трогают. type testEnv struct { mux http.Handler - handler *TranscribeHandler + handler *AppHandler app core.App journal *journalBuffer // session — значение сессии вошедшего. Приём и опрос закрыты за @@ -189,7 +189,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { logger, ) - handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger) + handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger) // Роутер собирается тем же способом, что и боевой: маршруты вешает сам // обработчик, и проверка судит ту же цепочку, что и прод. @@ -221,6 +221,16 @@ func createMultipartRequest(t *testing.T, fileName string, content []byte) *http // createMultipartRequestWithField кладёт запись в поле с заданным именем — // нужно, чтобы построить форму без поля `audio`. func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request { + return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content) +} + +// createMultipartRequestAt собирает тот же запрос по названному адресу — нужно +// проверке, судящей убранные адреса. +func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request { + return createMultipartRequestAtWithField(t, path, "audio", fileName, content) +} + +func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -233,12 +243,23 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte err = writer.Close() require.NoError(t, err) - req := httptest.NewRequest("POST", "/api/audio", &buf) + req := httptest.NewRequest("POST", path, &buf) req.Header.Set("Content-Type", writer.FormDataContentType()) return req } +// intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ +// списком всегда, даже на один файл: форма согласована один раз и вперёд. +func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem { + t.Helper() + + var items []IntakeItem + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items)) + require.Len(t, items, 1) + return items[0] +} + // storedFileNames отдаёт имена, под которыми файлы легли в хранилище. func storedFileNames(t *testing.T, env *testEnv) []string { records, err := env.app.FindAllRecords(migrations.FilesCollection) @@ -316,27 +337,32 @@ func TestCreateTranscribeJob_Success(t *testing.T) { require.Equal(t, http.StatusCreated, w.Code) - // Имена полей ответа нормативны: контракт HTTP API объявлен необратимым. - // Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал - // бы тег вместе с ожиданием, и проверка не смогла бы упасть. - var raw map[string]json.RawMessage - err := json.Unmarshal(w.Body.Bytes(), &raw) + // Имена полей ответа нормативны: контракт объявлен необратимым, а экраны + // строятся на этих именах. Судим по сырому JSON — разбор в структуру + // переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть. + // + // Ответ — **список**, даже когда файл в запросе один: форма согласована + // вперёд, чтобы приём нескольких файлов и распознавание повтора её не + // переписывали. + var rawItems []map[string]json.RawMessage + err := json.Unmarshal(w.Body.Bytes(), &rawItems) require.NoError(t, err) - assert.Contains(t, raw, "job_id") - assert.Contains(t, raw, "status") + require.Len(t, rawItems, 1) + assert.Contains(t, rawItems[0], "id") + assert.Contains(t, rawItems[0], "state") + assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд") + assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом") - var response CreateTranscribeJobResponse - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) + response := intakeItemOf(t, w) - assert.NotEmpty(t, response.JobID) + assert.NotEmpty(t, response.ID) assert.Equal(t, entity.StateUploaded, response.State) // Задача действительно заведена, а не только названа в ответе: иначе // отправитель получит идентификатор записи, которой не будет никогда. require.Equal(t, 1, countJobs(t, env)) - job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) require.NoError(t, err) assert.Equal(t, entity.StateUploaded, job.State) require.NotNil(t, job.OriginalFileID) @@ -359,7 +385,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) { req: func(t *testing.T) *http.Request { // Запрос строится так, как его видит сервер: у пришедшего по // проводу тело не бывает пустым указателем. - return httptest.NewRequest("POST", "/api/audio", http.NoBody) + return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody) }, }, { @@ -379,11 +405,12 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) { require.Equal(t, http.StatusBadRequest, w.Code) - var response map[string]string + var response ErrorBody err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Equal(t, "No audio file provided", response["error"]) + assert.Equal(t, CodeBadRequest, response.Code) + assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека") assert.Equal(t, 0, countFiles(t, env)) assert.Equal(t, 0, countJobs(t, env)) }) @@ -402,11 +429,9 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) { require.Equal(t, http.StatusCreated, w.Code) - var response CreateTranscribeJobResponse - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) + response := intakeItemOf(t, w) - assert.NotEmpty(t, response.JobID) + assert.NotEmpty(t, response.ID) assert.Equal(t, entity.StateUploaded, response.State) } @@ -492,15 +517,19 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { w := httptest.NewRecorder() env.serve(w, req) - require.Equal(t, http.StatusInternalServerError, w.Code) + // Негодная запись — отказ по причине, а не по месту: прежде здесь стоял + // `500`, и «файл не читается» приходило человеку как «сломался сервер». + require.Equal(t, http.StatusBadRequest, w.Code) - var response map[string]string + var response ErrorBody err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Причина отказа принадлежит журналу, а не отправителю. - assert.Equal(t, "Failed to create transcibe job", response["error"]) + assert.Equal(t, CodeBadRequest, response.Code) + assert.NotEmpty(t, response.Message) assert.NotContains(t, w.Body.String(), "не удалось прочитать запись") + assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом") assert.Equal(t, 0, countJobs(t, env)) } @@ -555,7 +584,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) { w := httptest.NewRecorder() env.serve(w, req) - require.Equal(t, http.StatusInternalServerError, w.Code) + require.Equal(t, http.StatusBadRequest, w.Code) journal := env.journal.String() @@ -603,10 +632,9 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { require.Equal(t, http.StatusCreated, w.Code) - var response CreateTranscribeJobResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + response := intakeItemOf(t, w) - job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) require.NoError(t, err) require.NotNil(t, job.OriginalFileID) @@ -690,19 +718,19 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) { job := jobWithFile(t, env) - req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody) + req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusOK, w.Code) - var response GetTranscribeJobResponse + var response RecordView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, job.Id, response.JobID) + assert.Equal(t, job.Id, response.ID) assert.Equal(t, entity.StateUploaded, response.State) - assert.NotZero(t, response.CreatedAt) + assert.NotEmpty(t, response.CreatedAt) } func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) { @@ -710,44 +738,48 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) { job := jobWithFile(t, env) - req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody) + req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusOK, w.Code) - // Судим по сырому JSON: пустая строка на месте отсутствующего текста - // читается клиентом как «расшифровка пуста», и разобранная структура - // эти два случая не различает. + // Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру + // переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть. var raw map[string]json.RawMessage require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) - assert.Contains(t, raw, "job_id") - assert.Contains(t, raw, "status") + assert.Contains(t, raw, "id") + assert.Contains(t, raw, "state") assert.Contains(t, raw, "created_at") - assert.NotContains(t, raw, "transcription_text") + assert.Contains(t, raw, "original_filename") + assert.Contains(t, raw, "duration_ms") + assert.Contains(t, raw, "size_bytes") + assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом") + assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности") } func TestGetTranscribeJobStatus_NotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody) + req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusNotFound, w.Code) - var response map[string]string + var response ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, "Job not found", response["error"]) + assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем") + assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека") } // Отправитель, у которого соединение оборвалось после полной загрузки, задачу // всё равно получает: запись доехала целиком, а результат он заберёт позже по -// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча — +// карточкой записи. Приём на контексте запроса терял бы такую запись молча — // решение владельца от 2026-08-13. func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) diff --git a/internal/entity/audio_record.go b/internal/entity/audio_record.go index e1c4179..dbaac56 100644 --- a/internal/entity/audio_record.go +++ b/internal/entity/audio_record.go @@ -1,7 +1,9 @@ package entity import ( + "strings" "time" + "unicode" "git.vakhrushev.me/av/transcriber/internal/clock" ) @@ -62,6 +64,33 @@ type AudioRecord struct { Title *string Brief *string + // OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от + // заголовка**: заголовок несёт название, которое дал человек либо посчитала + // языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока + // заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы + // имя, и вернуть затёртое было бы неоткуда. + // + // Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает + // управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант + // приватности. + OriginalFilename *string + + // DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма. + // Со строкой файла они намеренно не сверяются: там лежат величины той копии, + // которой файл является сейчас, и уточнение длительности меняет их, не трогая + // эти. Нужны колонками записи, потому что показываются в списке. + // + // Указатели здесь не выражают «неизвестно»: числовая колонка хранилища + // пустого значения не держит, и пустое кладётся нулём. Обе величины ставит + // приём и ставит всегда — запись с непрочитанными метаданными отвергается + // отказом и не заводится вовсе. Решение владельца 2026-08-15. + DurationMs *int64 + SizeBytes *int64 + + // TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено + // вперёд, заполняет его задача, считающая темы языковой моделью. + TopicIDs []string + State string // StateEnteredAt ставится только сменой рубежа и возвратом записи в работу. // Откладывание опроса его не двигает — иначе застревание в чужой операции @@ -99,6 +128,40 @@ type AudioRecord struct { UpdatedAt time.Time } +// MaxOriginalFilenameLen — потолок длины имени файла, данного отправителем. +// +// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому длина +// назначается сервисом. Число выведено из предела длины имени в распространённых +// файловых системах: имя длиннее 255 знаков не приходит от системного диалога +// выбора файла вовсе, и всё, что длиннее, — либо самодельный запрос, либо +// попытка раздуть строку записи. +const MaxOriginalFilenameLen = 255 + +// SanitizeOriginalFilename приводит имя, данное отправителем, к пригодному для +// хранения виду: убирает управляющие знаки и режет по потолку длины. +// +// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём, +// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что +// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки, +// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет. +// +// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка +// по байтам разрубила бы знак пополам. +func SanitizeOriginalFilename(name string) string { + cleaned := strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + + runes := []rune(cleaned) + if len(runes) > MaxOriginalFilenameLen { + runes = runes[:MaxOriginalFilenameLen] + } + return string(runes) +} + // AllStates — закрытый перечень рубежей для схемы хранилища. func AllStates() []string { out := make([]string, 0, len(stages)) diff --git a/internal/entity/filename_test.go b/internal/entity/filename_test.go new file mode 100644 index 0000000..c7ffb75 --- /dev/null +++ b/internal/entity/filename_test.go @@ -0,0 +1,69 @@ +package entity + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Имя файла приходит извне и содержимым своим приёму не подконтрольно. Правило +// чистки живёт в домене, а не в транспорте: имя доходит до колонки записи одним +// путём, и правил обязано быть одно. +func TestSanitizeOriginalFilename(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "обычное имя не трогается", + in: "разговор.mp3", + want: "разговор.mp3", + }, + { + name: "управляющие знаки убираются", + in: "разго\x00вор\x07\x1b.mp3", + want: "разговор.mp3", + }, + { + name: "перевод строки — тоже управляющий знак", + in: "первая\nвторая.mp3", + want: "перваявторая.mp3", + }, + { + name: "пустое остаётся пустым", + in: "", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, SanitizeOriginalFilename(tc.in)) + }) + } +} + +// Режется имя по знакам, а не по байтам: имя русское чаще, чем латинское, и +// обрезка по байтам разрубила бы знак пополам. +func TestSanitizeOriginalFilenameTrimsByRunes(t *testing.T) { + long := strings.Repeat("я", MaxOriginalFilenameLen+50) + + got := SanitizeOriginalFilename(long) + + assert.Len(t, []rune(got), MaxOriginalFilenameLen, "длина считается знаками") + assert.True(t, strings.HasPrefix(long, got), "обрезано с хвоста, а не переписано") + assert.Equal(t, long[:len(got)], got, "ни один знак не разрублен пополам") +} + +// Уборка идёт до резки: иначе потолок съедали бы знаки, которых в сохранённом +// имени всё равно не будет. +func TestSanitizeOriginalFilenameCleansBeforeTrimming(t *testing.T) { + dirty := strings.Repeat("\x00", 100) + strings.Repeat("я", MaxOriginalFilenameLen) + + got := SanitizeOriginalFilename(dirty) + + assert.Len(t, []rune(got), MaxOriginalFilenameLen, + "сто управляющих знаков не откусили сто знаков имени") +} diff --git a/internal/entity/stage.go b/internal/entity/stage.go index 226380d..e4c8fa8 100644 --- a/internal/entity/stage.go +++ b/internal/entity/stage.go @@ -76,6 +76,59 @@ func StageByName(name string) (Stage, bool) { return Stage{}, false } +// ListFilter — состояние записи, по которому её отбирает список приложения. +// +// Состояний три, а не два, и это не педантизм. Остановленная запись не в работе +// и не завершена: при отборе надвое она выпала бы из обеих половин — исчезла бы +// из списка при любом значении отбора, — хотя ради неё человек список и +// открывает. +type ListFilter string + +const ( + // ListFilterWorking — запись идёт по конвейеру. + ListFilterWorking ListFilter = "working" + // ListFilterHalted — запись остановлена признаком. + ListFilterHalted ListFilter = "halted" + // ListFilterDone — запись прошла конвейер. + ListFilterDone ListFilter = "done" +) + +// ParseListFilter узнаёт состояние отбора по его имени. Второе значение ложно у +// имени, которого в перечне нет: такой отбор — негодный ввод, а не пустая +// выборка. +func ParseListFilter(v string) (ListFilter, bool) { + switch ListFilter(v) { + case ListFilterWorking, ListFilterHalted, ListFilterDone: + return ListFilter(v), true + } + return "", false +} + +// TerminalStages — рубежи, из которых запись в работу не берут. +// +// Выводится из дескриптора наравне с WorkingStages: отбор списка — очередной +// потребитель словаря рубежей, и перечислять их у него строкой запроса нельзя. +// Рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх +// состояний отбора. +func TerminalStages() []Stage { + out := make([]Stage, 0, len(stages)) + for _, s := range stages { + if s.Terminal { + out = append(out, s) + } + } + return out +} + +// StageNames разворачивает рубежи в их имена — для запроса к хранилищу. +func StageNames(list []Stage) []string { + out := make([]string, 0, len(list)) + for _, s := range list { + out = append(out, s.Name) + } + return out +} + // StuckLimits — пределы простоя, приходящие из настроек. type StuckLimits struct { // Own — предел на своей работе. diff --git a/internal/entity/text.go b/internal/entity/text.go index 66bfc72..c85f858 100644 --- a/internal/entity/text.go +++ b/internal/entity/text.go @@ -12,6 +12,36 @@ const ( TextKindLiterary = "literary" ) +// Виды текста, которыми приложение спрашивает текст записи. +// +// Перечень закрыт, и каждое значение называет ровно одну хранимую вещь. Назван +// он так, а не парой «вид текста плюс форма показа», потому что реплики со +// временем — не вид текста: они лежат структурой разбора и принадлежат записи, а +// не тексту. Пара из двух параметров обещала бы сочетания, которых не существует. +// +// Вычитанный текст назван здесь вперёд, хотя считает его отдельная задача: +// перечень без него пришлось бы расширять правкой публичного контракта — того +// самого, который согласован один раз. +const ( + // TextViewTranscript — сырая расшифровка сплошным текстом. + TextViewTranscript = TextKindTranscript + // TextViewLiterary — вычитанный текст сплошным. + TextViewLiterary = TextKindLiterary + // TextViewReplicas — реплики со временем. + TextViewReplicas = "replicas" +) + +// IsKnownTextView — принадлежит ли вид закрытому перечню. Незаданный вид +// известным не считается: умолчание сделало бы ответ функцией того, что успел +// записать конвейер, а не состояния записи. +func IsKnownTextView(view string) bool { + switch view { + case TextViewTranscript, TextViewLiterary, TextViewReplicas: + return true + } + return false +} + // AllTextKinds — закрытый перечень видов текста для схемы хранилища. func AllTextKinds() []string { return []string{TextKindTranscript, TextKindLiterary} diff --git a/internal/metrics/format_label.go b/internal/metrics/format_label.go index 6ad3bc1..c636fce 100644 --- a/internal/metrics/format_label.go +++ b/internal/metrics/format_label.go @@ -1,6 +1,7 @@ package metrics import ( + "slices" "strconv" "strings" ) @@ -33,6 +34,33 @@ var knownFormats = map[string]struct{}{ "audio": {}, // умолчание сервиса, когда расширения в имени не было } +// PublicFormats — тот же перечень, но для подсказки диалогу выбора файла в +// приложении: **без** собственного умолчания сервиса. +// +// Второй перечень рядом с первым разошёлся бы с ним молча, поэтому источник +// один. `audio` из него вычтено: это не формат, а умолчание на случай имени без +// расширения, и подсказкой человеку оно выходить не должно. +// +// Сервис по этому перечню **не судит**: приём о годности записи не решает сам — +// расширение он берёт из имени файла, а пригодность содержимого узнаёт у +// источника метаданных. Перечень служит диалогу выбора файла, не более; норму +// держит capability `archive`. +func PublicFormats() []string { + out := make([]string, 0, len(knownFormats)) + for format := range knownFormats { + if format == defaultServiceFormat { + continue + } + out = append(out, format) + } + slices.Sort(out) + return out +} + +// defaultServiceFormat — собственное умолчание сервиса на случай имени без +// расширения. Форматом не является. +const defaultServiceFormat = "audio" + // FormatLabel приводит расширение к виду, годному для метки метрики. // // Расширение приходит из имени, которое дал отправитель, и потому может быть diff --git a/internal/service/find_job_test.go b/internal/service/find_job_test.go index c03c0a1..c247610 100644 --- a/internal/service/find_job_test.go +++ b/internal/service/find_job_test.go @@ -33,6 +33,14 @@ func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) { return nil, errors.New("не зовётся этими проверками") } +func (r *stubRecordRepo) List(contract.RecordQuery) (*contract.RecordPage, error) { + return &contract.RecordPage{}, nil +} + +func (r *stubRecordRepo) ResolveTopicNames(string, []string) (map[string]string, error) { + return map[string]string{}, nil +} + func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) { return nil, r.err } diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index d3cb1d9..350786d 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -22,6 +22,16 @@ import ( const ( defaultAudioExt = "audio" + // maxExtLen — потолок длины расширения вместе с точкой. + // + // Сторож от патологии, а не перечень допустимого: расширения известных + // форматов укладываются в пять знаков, и щедрый потолок ничего у отправителя + // не отнимает. Он нужен против другого — имени `x.` с четырьмястами знаками + // после точки: оно роняет заведение временного файла, и отправитель получает + // `500` на входе, за который отвечает сам, а владелец сервиса — строку `ERROR`, + // неотличимую от аварии хранилища. + maxExtLen = 32 + // Предел отказов. Число обратимо и живёт здесь одним местом; счётчик растёт // при захвате и обнуляется на шаге, завершившемся без отказа либо отложившем // работу. @@ -155,10 +165,15 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader } func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRecord, file io.Reader, fileName string) (*entity.AudioRecord, error) { - // Определяем расширение файла + // Расширение приходит из имени, которое дал отправитель, и потому может быть + // чем угодно. Длину назначает сервис: `filepath.Ext` режет по последней точке + // и всё, что после неё, берёт дословно, а имя `x.` плюс четыреста знаков + // роняет заведение временного файла — отправитель получал бы `500` на входе, + // за который отвечает сам, и клал бы в журнал владельца строку `ERROR`, + // неотличимую от аварии хранилища. ext := filepath.Ext(fileName) - if ext == "" { - ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено + if ext == "" || len(ext) > maxExtLen { + ext = fmt.Sprintf(".%s", defaultAudioExt) } // Собственное имя записи: идентификатор с расширением. Имя, данное @@ -184,7 +199,11 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec info, err := s.metaviewer.GetInfo(ctx, work.Path()) if err != nil { s.logger.Error("Failed to get file info", "error", err, "file_ext", ext) - return nil, err + // Признак заводится здесь, а не остаётся голой ошибкой источника + // метаданных: причина отказа — присланная запись, а не сбой сервиса, и + // без признака ветвь по умолчанию отдала бы `500`. «Файл негоден» + // читалось бы как «сломался сервер», и человек не понял бы, что делать. + return nil, fmt.Errorf("%w: %w", contract.ErrRecordUnreadable, err) } size, err := work.Size() @@ -217,6 +236,23 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec r.OriginalFileID = &fileRecord.Id r.StateEnteredAt = clock.Now() + // Имя, данное отправителем, доходит до самой записи — по нему человек узнаёт + // её, пока заголовка нет. В имя файла хранилища и в журнал оно по-прежнему не + // идёт: инвариант приватности не тронут, сужена только область его действия. + // + // Колонка заголовка остаётся пустой: приём заголовков не сочиняет, а + // посчитанное языковой моделью название легло бы поверх имени, если бы они + // делили одну колонку. + if cleaned := entity.SanitizeOriginalFilename(fileName); cleaned != "" { + r.OriginalFilename = &cleaned + } + + // Величины принятого — снимок с этой минуты. Со строкой файла они намеренно не + // сверяются: там лежат величины сегодняшней копии, и уточнение длительности + // меняет их, не трогая эти. + r.DurationMs = &meta.DurationMs + r.SizeBytes = &size + if err := s.repos.Records.Create(r); err != nil { s.logger.Error("Failed to create audio record", "error", err, "file_id", fileRecord.Id) return nil, err diff --git a/journal_route_test.go b/journal_route_test.go index 66bdcf9..f8179a4 100644 --- a/journal_route_test.go +++ b/journal_route_test.go @@ -27,13 +27,13 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) { }, { name: "прочие пути не трогаются", - path: "/api/status/abc123def456ghi", - want: "/api/status/abc123def456ghi", + path: "/app/audiorecords/abc123def456ghi", + want: "/app/audiorecords/abc123def456ghi", }, { name: "приём не трогается", - path: "/api/audio", - want: "/api/audio", + path: "/app/audiorecords", + want: "/app/audiorecords", }, { name: "сам префикс без имени не портится", diff --git a/main.go b/main.go index e03737b..18674a0 100644 --- a/main.go +++ b/main.go @@ -159,7 +159,7 @@ func main() { // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // и второму серверу на нём взяться неоткуда. - transcribeHandler := httpcontroller.NewTranscribeHandler(recordRepo, repos.Texts, transcribeService, logger) + appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger) authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{ AuthURL: cfg.Auth.AuthURL, RedirectURL: cfg.Auth.RedirectURL, @@ -214,8 +214,16 @@ func main() { return fmt.Errorf("failed to apply provider settings: %w", err) } + // Своё правило ограничителя частоты под корень приложения. Правило + // хранилища настроено на его собственный корень и наших адресов больше не + // покрывает: вместе с переездом приложения ограничитель перестал бы + // существовать для него вовсе, и заметить это было бы нечем. + if err := httpcontroller.ApplyAppRateLimit(storage); err != nil { + return fmt.Errorf("failed to apply app rate limit: %w", err) + } + authHandler.Register(se.Router) - transcribeHandler.Register(se.Router) + appHandler.Register(se.Router) se.Router.GET("/health", func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]string{ diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/.openspec.yaml b/openspec/changes/archive/2026-08-15-app-json-contract/.openspec.yaml new file mode 100644 index 0000000..0c73c8f --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-15 diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/design.md b/openspec/changes/archive/2026-08-15-app-json-contract/design.md new file mode 100644 index 0000000..864b508 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/design.md @@ -0,0 +1,219 @@ +## Context + +Сервис отвечает сегодня двумя адресами: приём и опрос готовности. Оба висят в +пространстве, которое принадлежит хранилищу, оба решают сами, каким кодом +ответить на отказ, и оба выросли из одного потребителя — внешней программы, +которой у сервиса сегодня нет. + +Приложение строят следующие задачи очереди: каркас, экран загрузки, список, +действия над записью. Все четыре опираются на форму ответа, и переписывать её +на каждой значит переделывать экраны вслед за ней. Поэтому контракт согласуется +здесь **целиком и один раз** — включая места под то, что заполнят соседние +задачи: признак повторного файла, заголовок и темы от языковой модели, поток +аудио, настройки человека. + +Стадия проекта — стройка: данных на сервере нет, сервис остановлен, выкладка +пойдёт с чистого листа. Совместимость с прежним контрактом поэтому не требуется, +и ломка объявляется прямо, а не переживается вторым адресом. + +## Goals / Non-Goals + +**Goals:** + +- один контракт на приём и чтение, с кодом ответа по причине отказа; +- одно место, где доменная ошибка становится кодом и сообщением; +- собственное пространство адресов у приложения; +- список, читаемый без содержимого записи; +- пределы сервера, объявленные сервером. + +**Non-Goals:** + +- экранов не делаем ни одного — их берут `spa-skeleton` и задачи экранов; +- признак повторного файла контракт **называет** местом в ответе, но не + заполняет: хозяин — `dedup-by-content-hash`; +- поток аудио (`GET /app/audiorecords/{id}/audio`) и настройки человека + (`/app/me/settings`) контракт не нормирует вовсе — ни адресом, ни местом в + карточке. Их форму выбирают `play-recording-in-app` и `settings-screen`, и + карточку они дополнят своим полем. Обещание «назвать, но не реализовать» + снято: названным считается только то, что стоит требованием спеки; +- заголовок и темы, которые считает языковая модель, заполняет + `llm-insights-adapter`; здесь только место под них; +- правка заголовка руками — колонка заведена, адрес принесёт + `audiorecord-actions`; +- аутентификация и владелец не заводятся: это `oidc-login` и `record-ownership`, + оба сделаны. + +## Decisions + +### Новая capability `archive`, а не расширение `intake` + +`intake` нормирует **приём** — что считается принятой записью и что с ней +происходит. Чтение своего архива — другое поведение с другим потребителем, и +сваленное в одну спеку оно размыло бы обе. + +Отвергнуто: **сложить всё в `intake`** — спека выросла бы вдвое и перестала +отвечать на свой вопрос. Отвергнуто: **спека на каждый адрес** — дробление +раньше расхождения требований, прямо против правила именования capability. + +Единая форма отказа и пределы сервера легли в `archive`, а не в `intake`, +потому что они свойство **всей** поверхности приложения, а не приёма: у нормы, +живущей в двух домах, дома нет. + +### Отображение доменной ошибки — одна функция, названная в архитектуре + +Сегодня такой точки нет, и это записано расхождением в +[conventions/errors.md](../../../docs/conventions/errors.md). Заводим её в слое +транспорта: доменная ошибка на входе, код и человекочитаемое сообщение на +выходе. Таблица соответствий — та, что уже стоит в конвенции; новая ветвь +заводится sentinel'ом и добавляется туда же. + +Отвергнуто: **каждый обработчик решает сам** — это сегодняшнее состояние, и +именно оно даёт `404` на упавшую базу. Отвергнуто: **отображение в доменном +слое** — код HTTP там не к месту, а второй транспорт (если появится) получил бы +чужие коды. + +Ошибок домена не хватает на все ветви: приём отвечает `400` на негодную запись, +а сегодня ошибка источника метаданных доезжает голой. Заводим ей свой признак — +иначе ветвь по умолчанию отдаст `500`. + +### Переезд в `/app/`, а слой сессии — на корень + +Слой предъявления сессии вешается на группу корня приложения, а не на перечень +адресов: перечень рос бы с каждым новым адресом, и забытый в нём адрес молча +перестал бы принимать куку. + +Своё правило ограничителя частоты заводится под тот же корень: правило +хранилища настроено на его собственный корень и наших адресов больше не +покрывает. Цена названа прямо, потому что это тихая потеря — без правила +ограничителя нет вовсе, и заметить это нечем. + +### Три новых колонки записи и один шаг схемы + +`original_filename`, длительность и размер ложатся колонками записи. Длительность +и размер лежат сегодня строкой файла, а список по норме `storage` читается без +содержимого — доставать их строкой файла значит читать по строке на каждую +запись списка. + +Шаг схемы **один на все три**: применённый шаг не переписывается, и три шага +вместо одного стоили бы трёх необратимых решений там, где хватает одного. + +Колонки правятся в двух местах пакета хранилища плюс шаг схемы — инвариант +проекта, компилятор их расхождения не видит. Имя файла и длительность с размером +при этом кладёт **приём**, а не конвейер: в `applyOwnedByPipeline` они не +попадают, иначе снимок шага стёр бы их. + +Отвергнуто: **держать имя файла в колонке заголовка** — посчитанный заголовок +затирал бы то, по чему человек узнаёт свою запись. + +Отвергнуто на чекпоинте 2026-08-15: **брать длительность и размер у строки файла +батчем на страницу**. Довод против колонок был в том, что обе величины уже лежат +у файла и станут копиями, которые некому держать равными; решением владельца +колонки остаются, а равенство объявлено ненужным прямо — на записи лежит снимок +принятого, на файле величины сегодняшней копии, и это разные вопросы. Норма +записана спекой `storage`, иначе два числа читались бы как копии одного. + +### Страница задаётся ключом, а не номером + +Решение владельца на чекпоинте 2026-08-15. Приём пишет в голову той же ленты, +которую читает список, и номер страницы сдвигал бы окно при первой же записи, +заведённой между двумя запросами: один элемент пришёл бы дважды, другой не пришёл +бы никогда, и оба раза молча. + +Ключ непрозрачен и задаёт положение полным ключом сортировки — парой «время +заведения и идентификатор»: у записей одного запроса время совпадает, и порядок +между ними иначе не определён. + +Отвергнуто: **номер страницы** — форма совпала бы с той, какой отдаёт страницы +хранилище, и позволила бы экран с нумерацией; цена — молчаливая потеря записи из +выдачи. Экран с нумерацией страниц архиву не нужен: листают его «дальше». + +### Имя отправителя: предел длины и уборка управляющих знаков + +Имя приходит извне и приёму не подконтрольно. Предел длины назначает сервис; +управляющие знаки убираются прежде сохранения, иначе они доедут до экрана и до +панели владельца. Инвариант приватности при этом не трогается: имя по-прежнему не +идёт ни в имя файла в хранилище, ни в журнал. + +### Темы в списке — названиями, а не ссылками + +Ничего сегодня темы не пишет: их посчитает `llm-insights-adapter`. Поле в ответе +всё равно заполняется названиями, а не идентификаторами: отдай мы ссылки, экран +не смог бы их показать, и форма ответа переделывалась бы задачей языковой модели — +ровно то, ради чего контракт согласуется здесь. + +### Текст отдаётся по названному виду — одним закрытым перечнем + +Видов три: `transcript`, `literary`, `replicas`. Перечень назван так, а не парой +«вид текста плюс форма показа», потому что реплики со временем — не вид текста: +они лежат структурой разбора и принадлежат записи. Пара из двух параметров +обещала бы сочетания, которых не существует. + +Вычитанный текст назван вперёд, хотя считает его отдельная задача: перечень без +него пришлось бы расширять правкой публичного контракта — того самого, который +согласуется здесь один раз. + +Отвергнуто: **«сплошной либо репликами»** — так стояло в постановке, и так +вычитанный текст недостижим вовсе. Отвергнуто: **два параметра** — половина их +сочетаний пуста. + +### Отказ несёт машиночитаемый код, а не только фразу + +Тело отказа несёт два поля: код из закрытого перечня и сообщение человеку. Кода +HTTP не хватает — «файл негоден», «поля записи нет» и «неизвестный вид» все три +`400`, а приложению надо решать, предлагать ли повтор. + +Отвергнуто: **одна фраза** — приложению осталось бы разбирать русский текст, и +первая же задача экрана добавила бы поле кода, то есть переписала бы контракт. + +### Имена полей названы спекой, а не выбраны кодом + +Контракт согласуется один раз ради того, чтобы экраны не переделывались. Имена +полей — часть контракта наравне с адресами: выбранные кодом, они станут +известны экранам, и переименование после этого стоит правки приложения. Форма +страницы — `items`, `next_cursor`, `total_items`: собственные адреса хранилища +приложению закрыты, и совпадать с его формой страницы не с чем. + +### Отбор списка различает три состояния, а не два + +Запись в работе, остановленная, прошедшая конвейер. Надвое не делится: +остановленная не в работе и не завершена, и при отборе надвое выпала бы из обеих +половин — исчезла бы из списка при любом значении, хотя ради неё список и +открывают. + +Предикат выводится из дескриптора рубежа, а не перечисляет рубежи строкой +запроса: отбор списка — очередной потребитель словаря рубежей, и инвариант +проекта запрещает перечислять их порознь. Правило `internal/archrules` +дописывается на нового потребителя здесь же, иначе инвариант держится памятью. + +### Отказ по превышению размера получает свою ветвь + +Потолок размера применяется уже сегодня, а ответ на его срабатывание не +нормирован ничем и уходит телом ограничителя тела — мимо единой формы. Это самый +частый отказ у человека на мобильной сети, и читается он сейчас как «сломался +сервер». Заводится своя доменная ветвь, код `413`, предел в теле числом. + +## Risks / Trade-offs + +- **Ломка публичного контракта необратима.** → Стадия — стройка, данных на + сервере нет, внешней программы на прежнем контракте не существует. Прежние + адреса отвечают `404`, а не отсутствуют молча. +- **Шаг схемы применён — не переписать.** → Три колонки заводятся одним шагом, и + состав их сверен со спекой `storage` до написания кода. +- **Правило ограничителя частоты можно забыть завести.** → Проверяется тестом: + настройки несут правило, чей адрес начинается корнем приложения. +- **Отбор списка по владельцу — место, где утечка стоит дороже всего.** → + Сужение владельцем лежит в репозитории, а не в обработчике, и пустой владелец + не совпадает ни с одной записью — норма `access` уже это держит. +- **Место под признак повтора и под темы заполняется не здесь.** → Форма + зафиксирована спекой, и соседние задачи её не переписывают, а заполняют. + +## Migration Plan + +Переносить нечего: на сервере данных нет, выкладка идёт с чистого листа. Шаг +схемы применяется на пустой базе. Откат — обратной правкой контракта, и она +запрещена решением владельца: контракт после мерджа не откатывается. + +## Open Questions + +Нет: четыре развилки контракта закрыты решением владельца 2026-08-15, состав +адресов согласован там же. diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/proposal.md b/openspec/changes/archive/2026-08-15-app-json-contract/proposal.md new file mode 100644 index 0000000..e5c22aa --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/proposal.md @@ -0,0 +1,70 @@ +## Why + +Приложение строить не на чем. Сегодня сервис отвечает «записи нет» на упавшую +базу и «внутренняя ошибка» на негодный файл: код ответа называет место, где +отказ случился, а не его причину. Экран, собранный на таком контракте, показывает +человеку «не найдено», когда на самом деле лежит хранилище. + +Списка своих записей у сервиса нет вовсе, карточка и текст едут одним ответом, а +пределы, которыми сервис ограничивает загрузку, приложению неоткуда узнать — +кроме как повторить их своей константой и разойтись с сервером молча. + +## What Changes + +- **BREAKING.** Опрос готовности убирается целиком вместе со своими именами + полей. Стадия проекта — стройка, на сервере данных нет, а внешней программы на + прежнем контракте не существует: своего токена у неё не было. +- **BREAKING.** Приложение уезжает из общего пространства `/api/` в своё `/app/`. + Общее пространство принадлежит хранилищу, и обновление библиотеки вправе занять + там имя рядом с нашим. +- **BREAKING.** Приём стоит тем же адресом, что и список, и отличается методом: + он заводит аудиозапись, а не кладёт файл. Ответ приёма отдаёт список заведённых + записей и место под признак повторного файла — форма согласуется один раз, + чтобы соседние задачи её не переписывали. +- Отказ отвечает своей причиной: сбой хранилища виден как сбой, негодная запись — + как негодная, отказ по чужому имени — как отказ. Тело отказа одной формы на + всех адресах приложения, и собирает его одно место. +- Появляется чтение своего архива: страница записей новыми сверху, карточка + записи без текста и текст названного вида — сплошной либо репликами со + временем. Шестичасовая расшифровка больше не задерживает показ шапки. +- Появляется адрес, которым сервис объявляет свои пределы: потолок размера + записи, частота опроса, перечень известных расширений, потолок тем. +- Запись подписывается именем файла, данным отправителем: имя ложится своей + колонкой и не спорит с заголовком, который дал человек либо посчитала языковая + модель. Длина ограничена, управляющие знаки убраны. +- Длительность и размер переезжают колонками записи: список читается без + содержимого, а обе величины приём узнаёт у источника метаданных и так. + +## Capabilities + +### New Capabilities + +- `archive`: архив своих записей глазами приложения — пространство адресов + приложения и единая форма отказа, пределы, которыми сервис ограничивает + загрузку, и чтение своего архива: страница записей, карточка и текст названного + вида. + +### Modified Capabilities + +- `intake`: приём переезжает на новый адрес и меняет форму ответа на список; + опрос готовности убирается целиком; имя, данное отправителем, доходит до самой + записи отдельной колонкой, оставаясь за пределами имени файла в хранилище и + журнала. +- `access`: область слоя предъявления сессии названа адресами приложения, и + среди них появляется вопрос «кто вошёл». +- `storage`: у записи появляются колонки имени файла отправителя, длительности и + размера. +- `pipeline`: исход своей записи владелец узнаёт карточкой записи, а не убранным + опросом готовности; два требования называли держателем нормы адрес, которого + больше нет. + +## Impact + +- Приём и чтение записей по HTTP: все адреса приложения, коды ответа и форма + тела. +- Схема хранилища: новый шаг под три колонки записи. +- Слой предъявления сессии и своё правило ограничителя частоты переезжают на + новый корень. +- Правило неизвестного пути в приложении перечисляет корни сервиса, а не один. +- Внешней программе на прежнем контракте ломается всё; такой программы у сервиса + сегодня нет. diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/review/code-review.md b/openspec/changes/archive/2026-08-15-app-json-contract/review/code-review.md new file mode 100644 index 0000000..b88cdfa --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/review/code-review.md @@ -0,0 +1,101 @@ +# Ревью кода — app-json-contract + +Метка `large`, режим «по графу». Состав: `autotests`, `specs`, `code`, +`architecture`, `adversary`, `ops`, `triage`. `basics` не запускался — своих тем +проекта нет, все темы ядра разобраны именными проходами. + +## План против исхода + +| Тема | Дом | Глубина | Кто закрыл | Исход | +| --- | --- | --- | --- | --- | +| `requirements` | `openspec/specs/` + дельты | разбор | `specs` | 7 находок | +| `autotests` | `CLAUDE.md`, «Гейт» | доказательство | `autotests` | гейт зелёный, 4 находки об отсутствующей верификации | +| `conventions` | `docs/conventions/` | разбор | `code` | 11 находок, потолок конвенционной половины сработал (4 из 4, за срезом двое) | +| `architecture` | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | 3 находки, потолок сработал | +| `security` | `docs/security.md` | доказательство | `adversary` | 3 построенных пути, 3 свойства | +| `operations` | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | 2 постмортема с замерами, 1 закрытая гипотеза | + +**Проход `ops` едва не остался незапущенным** — он ждал освобождения машины +после враждебного прохода, и оркестратор его не позвал. Поймал это триаж +сверкой плана с исходом; проход запущен и вернул две находки уровня `major`, +обе с замерами. Это дефект прогона, а не темы, и он записан здесь. + +## Что найдено и починено + +Каждая правка закрыта оракулом; тесты названы поимённо. + +| Находка | Проходы | Правка и оракул | +| --- | --- | --- | +| `413`, `429` и неизвестный путь под `/app` уходили телом библиотеки — мимо единой формы, ради которой заведена задача | architecture, code, specs, adversary | слой `OneErrorForm` плюс свой перехват неизвестного пути; `TestTooLargeOnTheRealPath`, `TestRateLimitRefusalGoesThroughOneErrorForm`, `TestUnknownAddressUnderAppRootUsesOneErrorForm` | +| объявленная частота опроса равнялась **всему** бюджету ограничителя | architecture, code, specs, adversary | частота выведена из доли бюджета; `TestPollIntervalLeavesBudgetHeadroom` | +| перечень доступных видов строился по ссылке, а не по содержимому: карточка обещала текст, которого адрес не отдал бы никогда | specs | перечень строится по содержимому; `TestEmptyTextIsNotAnAvailableView` | +| поле перечня пропадало из тела вместо пустого перечня | specs, code | поле стало указателем на срез и присутствует всегда; `TestRecordCard_NoTextYetGivesEmptyViews`, `TestCardAndPageItemShareOneShape` | +| ключ страницы с негодным временем принимался молча и отдавал пустой архив при непустом счётчике | specs, code | время разбирается и приводится к виду хранилища; `TestList_MalformedCursorIsRejected` (пять случаев) | +| выборка с непозитивным пределом роняла процесс обращением по индексу −1 | code | предел приводится к умолчанию в самом адаптере | +| неизвестное состояние отбора отдавало весь архив вместо отказа | code | ветвь отказа вместо молчаливого «без сужения» | +| разрешение тем шло без сужения владельцем | adversary | сужение добавлено; `TestForeignTopicDoesNotResolve` | +| длинное расширение из имени отправителя давало `500` и строку `ERROR` в журнале | adversary | потолок длины расширения; `TestIntake_AbsurdExtensionDoesNotBecomeInternalError` | +| `401` собирался руками мимо единой точки — на первой строке её собственной таблицы | code | заведён свой признак, ответ идёт через отображение | +| разрешение тем и перечень видов не исполнялись под тестом ни разу | autotests, triage | `TestTopicsResolveToNames`, `TestAvailableViewsCoverEveryKind` | +| ветвь замены правила ограничителя не исполнялась: правила копились бы с каждой выкладкой | autotests | повторный вызов в `TestRateLimitRuleCoversAppRoot` | +| **страница владельца сканировала весь архив сервиса** — замер: рост архива в 40 раз растил время страницы в 20–30 раз при неизменных сорока его записях | ops | два индекса в том же шаге схемы; `EXPLAIN QUERY PLAN` показывает покрывающий поиск вместо полного сканирования | +| **включение ограничителя схлопнуло все внутренние обмены OIDC-кода в один счётчик** — третий вход в пределах трёх секунд отвергался с текстом «Войти не удалось» | ops | внутреннему запросу задан адрес; регрессия внесена самим изменением и им же закрыта | + +## Решение человека по ходу ревью + +**Норма «непрочитанная длительность отличима от нулевой» снята.** Проверено +прогоном: числовая колонка хранилища пустого значения не держит, пустое кладётся +нулём, и ветвь кода была недостижима. Отличимость стоила бы четвёртой колонки +либо текстового типа у чисел; платить не за что — обе величины ставит приём и +ставит всегда, а запись с непрочитанными метаданными отвергается отказом и не +заводится вовсе. Правлены спека `storage`, `docs/database.md`, комментарии и +критерий приёмки; мёртвая ветвь убрана. + +## Урожай — реальное, но не для этого мерджа + +- **Ключ ограничителя частоты — адрес, а не учётная запись.** Свойство + библиотеки: двое за одним адресом делят бюджет. Арифметика против высокой + цены — объявленная частота даёт восьмерых одновременно опрашивающих на адрес. + Оракула на ущерб нет и быть не может: реального профиля нагрузки проект не + знает. +- **`TrustedProxy.Headers` не настроен нигде**, а сервис публикуется через + обратный прокси. Значит бюджет ограничителя считается по адресу прокси, то + есть общий на всех посетителей. Проверено чтением; на живом прокси не + воспроизводилось — его конфигурация вне репозитория. +- **Отказ ограничителя не виден в журнале контейнера** — канале, который + архитектура называет основным: встроенный слой стоит раньше нашего журнала + запросов. Виден только во внутренней таблице хранилища. +- **Уборка имени файла снимает только категорию Cc.** Разворот направления и + невидимые пробелы доезжают до колонки, до ответа и до имени файла на диске. + Видит это владелец записи и владелец сервиса. +- **Точность длительности.** Колонка названа в миллисекундах, а источник даёт + целые секунды: значение всегда кратно тысяче. +- **Род узла «читающий обработчик и список» в `docs/review.md` не заведён** — + свойства вроде устойчивости окна и потолка страницы там не спрашивает никто. + +## Границы покрытия + +- Метка `large`, режим «по графу», проходов семь. Потолки сработали у `code` + (конвенционная половина, 4 из 4) и у `architecture` (3 из 3) — оба объявили. +- **`ops` едва не остался незапущенным**, и поймал это только триаж. Строка + оставлена намеренно: пропуск был неотличим от прохода без находок. +- **Триаж получил дайджест оркестратора, а не сырые выводы проходов** — часть + их находок дошла до него уже починенной. Дедупликация выполнена над + дайджестом; находка, которую проход показал, а оркестратор не перечислил, для + триажа была невидима. +- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`) + прогоном не открывались: процессные документы. Расхождение изменения с + записанным решением ловится сверкой документации, а не ревью. +- Все числа отчёта сняты на этом прогоне: время страницы на 5k/50k/200k строк, + бюджет ограничителя, коды и тела ответов. +- Живой прогон сценария вошедшего локально невозможен по устройству проекта: + сессию выдаёт только провайдер OIDC, а локальный запуск наружу не ходит. + Этот путь закрыт машиной в тестах, через настоящий роутер и настоящее + хранилище. +- Не проверено ничем: поведение настоящих SpeechKit, Object Storage, Authelia и + обратного прокси; реальный профиль нагрузки и реальный размер архива; + стойкость `ffmpeg` к вредоносному входу; поведение браузера с куками. +- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет — + проход независимой реализации упразднён решением о стоимости. «Не знаю, чего + не знаю» здесь не достаёт никто, и на изменении, замораживающем публичную + форму ответов, это дорого. diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/review/design-review.md b/openspec/changes/archive/2026-08-15-app-json-contract/review/design-review.md new file mode 100644 index 0000000..5c2648c --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/review/design-review.md @@ -0,0 +1,97 @@ +# Ревью дизайна — app-json-contract + +Метка `large`, режим «по графу». Разметка: размер крупное, сложность +незнакомое; метку назвал агент `review-scope` до написания артефактов. + +Состав по метке: `specs` (режим «дизайн ДО кода»), `rubric`, `architecture` плюс +вопрос автору о трёх формах решения. Гейта на этой стадии нет — кода не +существует; триажа нет — сток стадии это отработка замечаний. + +## Что найдено и что с этим сделано + +Находок пятнадцать на три прохода, пересечения сведены. + +### Отработано правкой спек и дизайна + +| Находка | Проход | Правка | +| --- | --- | --- | +| Инвариант «запись не теряется молча» и два требования `pipeline` ссылались на убранный опрос готовности | specs | заведена дельта `pipeline`; карточка обязана нести рубеж, признак остановки и причину; в план добавлена правка `CLAUDE.md` | +| Состав полей карточки не нормирован, хотя на него ссылается `intake` | specs | карточка = элемент страницы плюс перечень доступных видов; имена полей названы поимённо | +| Перечень значений рубежа потерян вместе с убранным требованием | specs | возвращён нормой «принадлежит перечню рубежей конвейера», без перечисления порознь | +| Коды отказа на адресах чтения не заказаны; потерян запрет на разницу ответов без сессии | specs | перечень кодов закрыт и назван; `401` до всякого чтения записи; сценарий возвращён | +| «Вид текста» в `archive` значил форму показа, в `storage` — вид; вычитанный текст недостижим | specs | один закрытый перечень `transcript`/`literary`/`replicas` | +| Признак наличия текста булев, а состояние «текст есть, реплик нет» достижимо | rubric | перечень доступных видов вместо признака | +| «Текста ещё нет» без названного кода сливалось бы с `404` чужой записи | rubric, specs | код `409`, сценарий на отличие от `404` | +| Тело отказа несло только русскую фразу | rubric | два поля: машиночитаемый код из закрытого перечня и сообщение | +| Имена и единицы двух колонок из трёх не названы перед необратимым шагом схемы | rubric, architecture | `original_filename`, `duration_ms`, `size_bytes`; неизвестная длительность отличима от нулевой | +| Потолок размера страницы не назван — параметр обходит постраничность | rubric, specs | умолчание, потолок, усечение, `400` на негодное значение | +| Отказ по превышению размера тела шёл мимо единой формы | rubric | своя доменная ветвь, код `413`, предел в теле числом | +| Имена полей ответа не названы нигде | architecture | названы; форма страницы взята той же, какой её отдаёт хранилище | +| Отбор «в работе» — второй толкователь рубежей, остановленная запись выпадала из обеих половин | architecture, specs | три состояния вместо двух; предикаты выводятся из дескриптора рубежа; правило `archrules` на нового потребителя | +| Ветви `401`, `403`, `413` отсутствуют в таблице `docs/conventions/errors.md`, объявленной источником единой точки | architecture | шаг плана на правку конвенции | +| Два адреса обещаны дизайном, но не названы спекой | specs | обещание снято: названным считается то, что стоит требованием | + +### Ушло на чекпоинт человеку + +Три развилки — каждая расходится с тем, что владелец назвал в постановке, и +каждая необратима после мерджа. + +1. **Длительность и размер колонками записи против батч-разрешения строки + файла.** Обе величины уже лежат строкой файла; довод «по строке на запись + списка» опровергается собственным шагом плана — темы разрешаются одним + запросом на страницу. Цена ошибки: две вечные колонки-копии в применённом шаге + схемы, равенство которых не держит ничто. +2. **Перечень известных расширений в `GET /app/config`.** У сервиса нет понятия + «принимаемые форматы» — единственный такой перечень сужает метку метрики и + имеет другой смысл. Приложение прочитает перечень как «что можно загружать» и + станет единственным местом, где это правило существует. +3. **Постраничное чтение номером страницы против курсора.** Приём пишет в голову + той же ленты, которую читает список: запись, заведённая между двумя + страницами, сдвигает окно — один элемент придёт дважды, другой не придёт + никогда, и оба раза молча. + +## Второй круг: разметка и сверка после чекпоинта + +Правки после чекпоинта тронули дельта-спеки, поэтому повторены разметка и та +часть ревью дизайна, которой правка касается. Рубрика и архитектурный проход не +перезапускались намеренно: принятые правки — их же собственные рекомендации, и +судить их своим отчётом они не могут. + +**Разметка не сдвинулась** — крупное, незнакомое, `large`, теми же пятью +источниками. Правка синхронизировала спеки с решениями, уже стоявшими в дизайне, +а не добавила площадь или неизвестность. + +**Повторная сверка дала пять находок, все отработаны:** + +| Находка | Правка | +| --- | --- | +| `MODIFIED` требования `pipeline` вырезало обоснование двух сторожей — задача `failure-verdict-vs-retry` прочла бы урезанную норму | предложение возвращено целиком, сменён только держатель нормы | +| Критерии приёмки остались на прежней модели текста и вернули бы в код две уже закрытые находки: булев признак наличия текста и «тот же вид репликами»; оракул формы сравнивал не ту пару | критерии и шаги плана приведены к спеке, правка помечена в самом критерии | +| Половина имён публичного контракта осталась бы за кодом — поля тела отказа и сам перечень кодов, поля пределов и «кто вошёл», имя признака повтора, имена параметров запроса | все названы спекой поимённо | +| Два предела из объявляемых не имели проверяемого источника; имя «частота опроса готовности» протухало вместе с убираемым адресом | частота выведена из настройки ограничителя, перечень расширений — из меток метрики за вычетом `audio`, предел переименован в «частоту опроса карточки» | +| Паспорт, модель угроз и архитектура остались бы описывать убранные адреса, и плана правки на них не было | заведены три шага плана | + +Второго чекпоинта не было, и это осознанно: ни одна из пяти находок не меняла +решения, принятого человеком, — все они приводили спеки и план в согласие с уже +принятым. + +Что осталось названным, но не закрытым: имя капабилити `archive` совпадает +словом с каталогом заархивированных change и с тем, как паспорт зовёт весь +сервис. Имя оставлено — каталоги разные, слово в проекте своё. + +## Границы покрытия стадии + +- Метка `large`, режим «по графу»; проходов три, все вернулись, потолок не + срабатывал ни у одного — `rubric` вывел 8 из 8 построенных, `architecture` + упёрся в свои 3 и объявил это, `specs` потолка не имеет на этой метке. +- Кода не существует: ничего не запускалось, кроме `openspec validate --strict`. + Гейт на этой стадии не гоняется по построению. +- Команды сборки карты проекта в `Taskfile.yml` нет — архитектурный проход + собирал инвентарь понятий грепом, и такой инвентарь беднее подготовленного. +- `docs/adr/` и `docs/research/` прогоном не открывались: процессные документы. + Расхождение изменения с записанным решением этой стадией не ловится — это + работа сверки документации. +- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет: + проход независимой реализации упразднён решением о стоимости. +- Что будет с этим на живых данных, не проверял никто: данных нет, стадия — + стройка. diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/specs/access/spec.md b/openspec/changes/archive/2026-08-15-app-json-contract/specs/access/spec.md new file mode 100644 index 0000000..7d834f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/specs/access/spec.md @@ -0,0 +1,141 @@ +## MODIFIED Requirements + +### Requirement: Сессия предъявляется кукой + +Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и +своей страницы со скриптом для этого не требуется. Кука сессии MUST быть +недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному +соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта +(`SameSite=Lax` или строже). + +Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех +вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает. + +Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ +остаётся рабочим: его требуют собственные адреса аутентификации хранилища. +Сервис MUST перекладывать значение куки в этот заголовок **только когда +заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной +кукой получал бы не то, что предъявил на собственных адресах хранилища. + +Область действия слоя MUST быть ограничена **адресами приложения** — теми, что +живут под его собственным корнем. Собственная поверхность хранилища под него не +подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не +шлёт, и расширение слоя на всё сняло бы эту защиту молча. + +Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым +адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия, +предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы. + +#### Scenario: Кука открывает доступ + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка +- **THEN** запрос проходит + +#### Scenario: Кука защищена от чтения скриптом + +- **WHEN** сервис ставит куку сессии +- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite` + +#### Scenario: Предъявленный заголовок побеждает куку + +- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization` +- **THEN** проверку проходит значение заголовка, а не куки + +#### Scenario: Слой не расширяется на поверхность хранилища + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой +- **THEN** значение куки в заголовок не перекладывается + +### Requirement: У записи есть владелец, и чужую ей не отдают + +Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от +имени которой запись принята, — и MUST отдавать данные такой записи только её +владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни +рукой в панели: колонка владельца пустого значения не принимает, и норму эту +держит capability `storage`. + +Владелец назначается один раз, при приёме, и MUST не меняться: совместного +доступа, ролей и передачи записи другому сервис не знает. + +Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец, +пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое +имя. + +Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей. +Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице +ответов считывается, какие записи заведены, а идентификатор записи и есть то, +что разграничение прячет. Каким именно ответом это выражено, нормирует +capability `archive`: там живут адреса чтения записи, и держатель нормы обязан +быть один. + +Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны +**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище +больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной +записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от +схемы намеренно: схема запрещает **заводить** ничью запись, а это правило +запрещает **спрашивать** ничьим именем, и одно другое не заменяет. + +#### Scenario: Своя запись доступна + +- **GIVEN** человек вошёл и принял запись +- **WHEN** он спрашивает карточку этой записи своей сессией +- **THEN** ответ несёт данные записи + +#### Scenario: Чужая запись неотличима от несуществующей + +- **GIVEN** запись принята одним вошедшим +- **WHEN** её карточку спрашивает другой вошедший +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +#### Scenario: Владельца не задают запросом + +- **WHEN** запрос на приём записи несёт своё значение владельца +- **THEN** владельцем принятой записи становится предъявитель сессии + +#### Scenario: Ничью запись завести нечем + +- **WHEN** запись пытаются завести с пустым владельцем +- **THEN** хранилище её не сохраняет + +#### Scenario: Пустой владелец не открывает ничего + +- **GIVEN** заведены две записи: своя и чужая +- **WHEN** карточку каждой спрашивают с пустым владельцем +- **THEN** ответ на обе тот же, что и на неизвестный идентификатор + +## ADDED Requirements + +### Requirement: Приложение узнаёт вошедшего + +Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и +MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом, +которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает +разметку само и вошедшего узнаёт ответом. + +Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не +может вовсе — этот адрес единственный способ его узнать. + +Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями +`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и +принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает +ему выходить наружу наравне с журналом. + +#### Scenario: Вошедший узнан + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** приложение спрашивает, кто вошёл +- **THEN** ответ несёт идентификатор его учётной записи + +#### Scenario: Сессии нет + +- **WHEN** приложение спрашивает, кто вошёл, без сессии +- **THEN** ответ имеет код `401` +- **AND** тело ответа не несёт учётной записи + +#### Scenario: Адреса почты в ответе нет + +- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты +- **WHEN** приложение спрашивает, кто вошёл +- **THEN** адреса почты в ответе нет diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/specs/archive/spec.md b/openspec/changes/archive/2026-08-15-app-json-contract/specs/archive/spec.md new file mode 100644 index 0000000..a261336 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/specs/archive/spec.md @@ -0,0 +1,466 @@ +## ADDED Requirements + +### Requirement: Адреса приложения живут своим пространством + +Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не +занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно +вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он +литерал библиотеки, а не настройка. + +Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся: +обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они +молча — тем же адресом начнёт отвечать не тот обработчик. + +Цена переезда называется здесь же. Правило неизвестного пути, по которому +приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не +один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель +частоты хранилища настроен на чужой корень и наших адресов больше не покрывает, +поэтому сервис MUST заводить своё правило под корень приложения. + +Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность +и выключен умолчанием, поэтому включение нашего правила вводит в действие и его +собственные — на входе, на заведении записей и на его адресах. Принимается +сознательно: без включения наше правило не значит ничего. + +Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки +человека живут под корнем приложения. + +#### Scenario: Адрес приложения отвечает под своим корнем + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает список своих записей под корнем приложения +- **THEN** ответ приходит от сервиса, а не от хранилища + +#### Scenario: Прежние адреса приложения не отвечают + +- **GIVEN** заведена запись +- **WHEN** её спрашивают прежними адресами в чужом пространстве +- **THEN** ответ имеет код `404` + +#### Scenario: Ограничитель частоты покрывает адреса приложения + +- **WHEN** сервис поднялся +- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем + приложения + +### Requirement: Отказ называет причину, а не место + +Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине** +отказа, а не месту, где он случился. Перечень закрыт и назван поимённо: + +- отсутствие сессии — `401`, и он MUST наступать **до всякого чтения записи**, + одинаково для заведённой записи и для неизвестного идентификатора: иначе по + разнице кодов перебирается список заведённых записей; +- узнанный предъявитель без учётной записи пользователя — `403`; +- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST + отвечать чужая и ничья запись; +- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, + негодный размер страницы; +- запись сверх потолка размера — `413`, и тело MUST нести предел числом; +- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у + записи ещё нет; +- отказ хранилища и всякая неназванная причина — `500`. + +Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все +адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места +нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую +базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как +«моя запись пропала», а второе не говорит ему ничего. + +Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** +поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное +человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы +различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» — +все три `400`, — а приложению надо решать, предлагать ли повтор и что показать +человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же +задача экрана переписала бы контракт, согласованный здесь один раз. + +Имена полей и перечень кодов нормативны — их разбирает каждый экран, и +выбранные кодом они стали бы контрактом молча: + +- поля тела: `error_code` и `message`; +- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`, + `bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`. + +Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, +неизвестный путь под корнем приложения, — и до отображения доменной ошибки не +доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на +адресах приложения две, а самый частый отказ у человека на мобильной сети — +«запись больше потолка» — приходит телом библиотеки, без кода и без предела +числом. + +Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа +заводится добавлением в него, а не строкой в обработчике: иначе ветвь по +умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в +журнале аварию там, где её нет. + +Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали +устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка +остаётся в журнале владельца сервиса. + +#### Scenario: Сбой хранилища виден как сбой + +- **GIVEN** хранилище отвечает отказом драйвера на чтение записи +- **WHEN** владелец спрашивает свою запись +- **THEN** ответ имеет код `500` +- **AND** тела записи в ответе нет + +#### Scenario: Негодная запись видна как негодная + +- **GIVEN** источник метаданных не может прочитать присланную запись +- **WHEN** отправитель шлёт её приёмом +- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку +- **AND** причина отказа в тело ответа не попадает + +#### Scenario: Отказ по пустому владельцу + +- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет +- **WHEN** он шлёт запись приёмом +- **THEN** ответ имеет код `403` + +#### Scenario: Форма тела одна на всех ветвях отказа + +- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по + отсутствию учётной записи и по сбою хранилища +- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же + полями +- **AND** код отказа принадлежит закрытому перечню +- **AND** ни одно из них не содержит сырого текста ошибки + +#### Scenario: Без сессии неизвестная запись неотличима от заведённой + +- **GIVEN** заведена запись +- **WHEN** её карточку спрашивают без сессии, а затем спрашивают карточку по + неизвестному идентификатору +- **THEN** оба ответа имеют код `401` и одно тело + +#### Scenario: Запись сверх потолка размера + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись длиннее потолка размера +- **THEN** ответ имеет код `413`, а тело несёт предел числом +- **AND** ни файла, ни аудиозаписи не заводится + +### Requirement: Сервис объявляет свои пределы + +Сервис SHALL отдавать свои пределы отдельным адресом — `GET /app/config` — и +MUST называть в нём потолок размера одной записи, потолок размера страницы, +частоту опроса карточки, перечень известных расширений и потолок числа тем у +записи. + +Предел зовётся частотой опроса **карточки**, а не готовности: адрес опроса +готовности это же изменение убирает целиком, и читатель через месяц искал бы то, +чего нет. + +Имена полей ответа нормативны: `max_record_size_bytes`, `max_page_size`, +`poll_interval_ms`, `known_extensions`, `max_topics_per_record`. + +**Каждый объявленный предел MUST быть тем же значением, которое сервис +применяет, а не его копией.** Правило общее, а не про один потолок размера: +приложение, знающее предел своей константой, расходится с сервером молча — до +первого отказа на записи, которую человек уже успел отправить по мобильной сети. +Ровно то же случается, когда предел объявлен сервером, но взят из второй +константы рядом с применяемой. + +Отсюда источник у каждого: + +- потолок размера записи — то число, которым сервис ограничивает тело запроса + приёма и отвергает запись кодом `413`; +- потолок числа тем у записи — то число, которым его ограничивает схема + хранилища; норму держит capability `storage`; +- потолок размера страницы — то число, до которого сервис усекает запрошенный + размер страницы; +- частота опроса — выводится из **доли** бюджета ограничителя частоты под корнем + приложения и MUST не задаваться своей константой. Доля, а не весь бюджет: + опрос идёт не один — в ту же секунду приложение листает список, открывает + соседнюю карточку и грузит новую запись, а бюджет один на все адреса + приложения и считается по адресу спрашивающего, а не по учётной записи. + Объявленная частота, равная всему бюджету, отдавала бы отказ на любом втором + запросе — тот самый, которого объявление обещает избежать. Иначе приложение, + честно опрашивающее карточку с объявленной частотой, упирается в собственный + ограничитель сервиса — и получает отказ, которого сервис сам же ему и обещал + избежать; +- перечень известных расширений — тот же, что сужает метку метрики, за вычетом + собственного умолчания сервиса `audio`: оно не формат, и подсказкой человеку + выходить не должно. Второй перечень рядом с первым разошёлся бы с ним молча. + +**Перечень известных расширений — исключение в другом: сервис по нему не +судит.** Приём о годности записи не судит сам — расширение он берёт из +имени файла, а пригодность содержимого узнаёт у источника метаданных, — и +перечень служит приложению подсказкой для диалога выбора файла, не более. +Умолчать об этом нельзя: приложение прочитало бы перечень как «что можно +загружать» и отвергало бы запись, которую сервис принял бы и расшифровал. + +Адрес MUST быть доступен тому же, кому доступны прочие адреса приложения: +пределы не тайна, но отдельного открытого адреса ради них не заводится. + +#### Scenario: Потолок размера равен тому, которым сервис отвергает + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает пределы сервиса +- **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает + тело запроса приёма + +#### Scenario: Потолок страницы равен применяемому + +- **WHEN** человек спрашивает пределы сервиса, а затем просит страницу размером + сверх объявленного потолка +- **THEN** размер отданной страницы не превышает объявленного потолка + +### Requirement: Страница своих записей + +Сервис SHALL отдавать владельцу страницу его записей — `GET /app/audiorecords` — +новыми сверху, и MUST не показывать в ней ни одной чужой записи. Спрашивающий с +пустым именем MUST не получать ни одной записи. + +Ответ MUST нести страницу, ключ следующей страницы и общее число записей. Число +записей одного человека растёт годами — сервис объявлен архивом, — и ответ без +страниц перестал бы помещаться в память телефона. + +**Страница задаётся ключом, а не номером.** Приём пишет в голову той же ленты, +которую читает список, и человек, загрузивший запись и листающий свой архив, — +штатный сценарий, а не редкость. Номер страницы сдвинул бы окно на единицу: +последний элемент первой страницы пришёл бы вторым разом первым элементом второй, +а один элемент между ними не пришёл бы никогда. Отказ молчаливый — ни кода, ни +строки в журнале, — и человек видел бы архив, в котором записи нет. + +Ключ MUST быть непрозрачным для спрашивающего и MUST задавать положение +**полным** ключом сортировки — парой «время заведения и идентификатор». Одного +времени мало: у записей, принятых одним запросом, оно совпадает, и порядок между +ними иначе не определён вовсе. + +Ключа следующей страницы нет — страница последняя; пустая страница MUST отвечать +успехом, а не отказом: отсутствие записей не есть ошибка. + +Ключ, который сервис не может прочитать — протухший, обрезанный, подделанный, — +MUST давать отказ по негодному вводу. Молчаливая отдача первой страницы вместо +этого дала бы человеку архив, листающийся по кругу, и ни строки в журнале. + +**Сторона запроса нормируется наравне со стороной ответа.** У размера страницы +MUST быть умолчание и потолок; размер сверх потолка MUST усекаться до него, а не +отвергаться, а негодное значение — ноль, отрицательное, нечисловое — MUST давать +отказ по негодному вводу. Незаданный потолок был бы способом попросить весь архив +одним запросом, то есть обойти постраничность тем самым параметром, ради которого +она заведена. + +Имена полей ответа и элемента нормативны: экраны строятся на них, и +переименование после того, как экран написан, стоит правки приложения. + +- параметры запроса: `cursor`, `limit`, `filter`; +- страница: `items`, `next_cursor`, `total_items`; +- элемент: `id`, `title`, `original_filename`, `brief`, `topics`, `state`, + `halted`, `halt_reason`, `duration_ms`, `size_bytes`, `created_at`. + +Значение `state` MUST принадлежать перечню рубежей конвейера, а `halt_reason` — +перечню причин остановки. Оба перечня объявлены одним местом, и перечислять их +порознь в потребителе нельзя: рубеж, добавленный конвейером, иначе разошёлся бы +с ответом молча. + +Чтение страницы MUST не читать ни расшифровки, ни структуры реплик: обе лежат +порознь от записи ровно затем, чтобы список их не тянул. Длительность и размер +MUST браться колонками самой записи, а не строкой её файла. + +Машинный текст отказа MUST в элемент страницы не попадать: он принадлежит +журналу владельца сервиса. Причина остановки — значение из закрытого перечня, и +она не он. + +Значения причины остановки этим требованием впервые выходят в публичный ответ, и +это осознанно: без причины признак остановки не говорит человеку, чего ждать — +повтора, своего действия или ничего. Превращает значение в русскую фразу +**приложение**, а не сервис: сервис отдаёт значение перечня, и второй словарь +фраз на стороне сервера разошёлся бы с тем, что показывает экран. + +**Отбор MUST различать три состояния, а не два:** запись в работе (`working`), +запись остановлена (`halted`), запись прошла конвейер (`done`). Незаданный отбор +значит «все». Двух значений не хватает: остановленная +запись не в работе и не завершена, и при отборе надвое она выпала бы из обеих +половин — то есть исчезла бы из списка при любом значении отбора, хотя ради неё +человек список и открывает. Предикат каждого состояния MUST выводиться из +дескриптора рубежа и признака остановки, а не перечислять рубежи строкой запроса: +рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх. + +#### Scenario: Страница отдаётся новыми сверху + +- **GIVEN** владелец завёл записей больше, чем помещается на страницу +- **WHEN** он спрашивает первую страницу +- **THEN** в ней лежит ровно столько записей, сколько вмещает страница +- **AND** первой стоит заведённая последней +- **AND** ответ несёт общее число его записей и ключ следующей страницы + +#### Scenario: Запись, заведённая между страницами, окна не сдвигает + +- **GIVEN** владелец прочитал первую страницу и взял ключ следующей +- **WHEN** он заводит новую запись и спрашивает следующую страницу этим ключом +- **THEN** ни один элемент первой страницы в ней не повторяется +- **AND** ни одна запись между страницами не пропущена + +#### Scenario: Записи с одним временем заведения идут в устойчивом порядке + +- **GIVEN** две записи заведены одним запросом и время заведения у них совпадает +- **WHEN** владелец читает страницу дважды +- **THEN** порядок этих записей в обоих ответах один и тот же + +#### Scenario: Последняя страница + +- **WHEN** владелец дочитал архив до конца +- **THEN** ответ имеет код `200`, а ключа следующей страницы в нём нет + +#### Scenario: Чужих записей в странице нет + +- **GIVEN** записи заведены двумя вошедшими +- **WHEN** страницу спрашивает один из них +- **THEN** в ней лежат только его записи + +#### Scenario: Список не тянет расшифровку + +- **GIVEN** у записи есть расшифровка +- **WHEN** владелец спрашивает страницу своих записей +- **THEN** текста расшифровки в ответе нет +- **AND** чтение страницы строку текста не трогает + +#### Scenario: Размер страницы сверх потолка усекается + +- **WHEN** владелец просит страницу размером больше объявленного потолка +- **THEN** ответ имеет код `200`, а размер страницы равен потолку + +#### Scenario: Негодный размер страницы отвергается + +- **WHEN** владелец просит страницу размером ноль либо нечисловым значением +- **THEN** ответ имеет код `400` + +#### Scenario: Остановленная запись видна отбором + +- **GIVEN** у владельца есть запись в работе, остановленная запись и прошедшая + конвейер +- **WHEN** он спрашивает каждое из трёх состояний отбором +- **THEN** каждая запись приходит ровно в одном из них +- **AND** остановленная приходит с признаком остановки и её причиной + +### Requirement: Карточка записи отдаётся без текста + +Сервис SHALL отдавать владельцу карточку одной записи — `GET +/app/audiorecords/{id}` — и MUST не класть в неё текста расшифровки. + +**Карточка несёт те же поля, что и элемент страницы, плюс перечень доступных +видов текста** — полем `available_views`. Две формы одной вещи разошлись бы +молча, поэтому состав задан одной нормой, а не двумя. + +Отсюда обязанность, которой держится инвариант проекта «принятая запись не +теряется молча»: карточка MUST нести рубеж, признак остановки и её причину. +Прежде исход своей записи владелец узнавал опросом готовности; опрос убран, и +единственным местом, где отправитель узнаёт о неудаче, становится карточка. Норма +эта переехала сюда целиком — capability `pipeline` называет держателем её этот +адрес. + +Вид считается доступным по **содержимому**, а не по наличию ссылки на текст. +Ссылка без содержимого — состояние штатное: пустой ответ распознавания сервис +признаёт нормой и записывает его в журнал. Строй мы перечень по ссылкам, +карточка объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» +вечно — приложение опрашивало бы его без конца, а человек видел бы завершённую +запись, из которой текст «вот-вот появится». + +Перечень MUST присутствовать в ответе **всегда**, в том числе пустым: отсутствие +поля и пустой перечень приложение не различит, а значат они разное. + +Перечень доступных видов MUST быть **перечнем**, а не признаком «текст есть». +Видов больше одного, и шаг завершения пишет их несколькими операциями: состояние +«сплошной текст есть, реплик ещё нет» достижимо. Один признак на несколько видов +отправил бы приложение за репликами, которых нет, — и исход стал бы функцией +того, в каком месте прервался шаг, а не состояния записи. Пустой перечень значит +«текста ещё нет». + +Шестичасовая расшифровка, приехавшая вместе с шапкой записи, задерживает показ +на мобильной сети на то время, которое человеку не нужно ждать: шапку он читает +сразу, а текст — если решил читать. + +Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный +идентификатор. + +#### Scenario: Карточка без текста + +- **GIVEN** у записи есть расшифровка +- **WHEN** владелец спрашивает её карточку +- **THEN** поля с текстом в ответе нет +- **AND** перечень доступных видов несёт сырую расшифровку + +#### Scenario: Остановленная запись видна карточкой + +- **GIVEN** запись остановлена признаком по исчерпании отказов +- **WHEN** владелец спрашивает её карточку +- **THEN** карточка несёт достигнутый рубеж, признак остановки и её причину +- **AND** машинного текста отказа в ответе нет + +#### Scenario: Текста ещё нет + +- **GIVEN** запись не дошла до расшифровки +- **WHEN** владелец спрашивает её карточку +- **THEN** перечень доступных видов пуст + +#### Scenario: Чужая карточка неотличима от неизвестной + +- **GIVEN** запись заведена одним вошедшим +- **WHEN** её карточку спрашивает другой вошедший +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +### Requirement: Текст записи отдаётся названным видом + +Сервис SHALL отдавать текст записи отдельным адресом — `GET +/app/audiorecords/{id}/text` — и MUST отдавать **вид, названный спрашивающим**. +Отдача «последнего записанного» сделала бы ответ функцией порядка записи, а не +состояния записи. + +**Перечень видов закрыт, и каждое его значение называет ровно одну хранимую +вещь:** + +- `transcript` — сырая расшифровка сплошным текстом; +- `literary` — вычитанный текст сплошным; +- `replicas` — реплики со временем. + +Перечень назван так, а не парой «вид текста плюс форма показа», потому что +реплики со временем — не вид текста: они лежат структурой разбора и принадлежат +записи, а не тексту. Пара из двух параметров обещала бы сочетания, которых не +существует. + +Вычитанный текст назван здесь, хотя считает его отдельная задача: перечень, +заведённый без него, пришлось бы расширять правкой публичного контракта — того +самого, который согласуется здесь один раз. До появления вычитанного текста +значение просто не встречается в перечне доступных видов у карточки. + +Значения `transcript` и `literary` MUST совпадать с видами текста, объявленными +хранилищем: два словаря об одном разошлись бы молча. + +Текста запрошенного вида нет — сервис MUST отвечать кодом `409`, а не пустой +строкой и не `404`. Пустая строка читается как «расшифровка пуста»; `404` слился +бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на +своей записи, загруженной минуту назад, — ровно тот отказ, ради устранения +которого заводится весь контракт. + +Вид, которого сервис не знает, и незаданный вид MUST давать отказ по негодному +вводу: умолчание сделало бы ответ функцией того, что успел записать конвейер. + +Текст чужой записи MUST быть недоступен наравне с её карточкой. + +#### Scenario: Сырая расшифровка сплошным текстом + +- **GIVEN** у записи есть сырая расшифровка +- **WHEN** владелец спрашивает её текст видом `transcript` +- **THEN** ответ несёт содержимое сырой расшифровки + +#### Scenario: Реплики со временем + +- **GIVEN** у записи есть структура реплик +- **WHEN** владелец спрашивает её текст видом `replicas` +- **THEN** ответ несёт реплики, и у каждой стоит её время + +#### Scenario: Текста этого вида ещё нет + +- **GIVEN** у записи есть сырая расшифровка и нет структуры реплик +- **WHEN** владелец спрашивает её текст видом `replicas` +- **THEN** ответ имеет код `409` +- **AND** он отличается от ответа на неизвестный идентификатор + +#### Scenario: Вид неизвестен или не назван + +- **WHEN** владелец спрашивает текст видом, которого сервис не знает, либо не + называет вида вовсе +- **THEN** ответ имеет код `400` diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/specs/intake/spec.md b/openspec/changes/archive/2026-08-15-app-json-contract/specs/intake/spec.md new file mode 100644 index 0000000..a432b6f --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/specs/intake/spec.md @@ -0,0 +1,280 @@ +## MODIFIED Requirements + +### Requirement: Приём записи по HTTP + +Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом +`multipart/form-data` и полем `audio` **только от узнанного отправителя**. +Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, +ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и +получить заведённую под неё аудиозапись на рубеже `uploaded`. + +Приём стоит тем же адресом, что и список записей, и отличается от него только +методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло +содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя +запись он и создаёт. + +Ответ MUST нести **список** заведённых записей и место под признак повторного +файла у каждой, даже когда файл в запросе один. Форма согласована один раз и +вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом +нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки — +переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним: +меняется форма ответа, не число файлов. + +Элемент списка MUST нести те же поля, что и карточка записи, плюс признак +повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча. +Состав карточки нормирует capability `archive`. + +Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес +опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка +публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней +программы на прежнем контракте не существует — своего токена у неё не было. + +Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не +перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и +перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет +достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе. + +Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи, +и код с телом такого отказа нормирует capability `archive` наравне с прочими +ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание — +самый частый отказ у человека на мобильной сети — прежде не был нормирован +ничем и уходил телом ограничителя тела, мимо единой формы. + +Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую +не заплатит узнанный отправитель, не должна попасть даже в память. + +Приём не судит о годности записи сам: расширение он берёт из имени файла, а +пригодность содержимого узнаёт у источника метаданных. + +Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает +хранилище, и нормирует её capability `storage`. + +Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность +владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого +значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в +приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как +запись попадёт в память, а схема отвечала бы отказом сохранения после укладки +файла. + +Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать +отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по +отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё +же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он +стать не может. + +Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит +«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет — +оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены. + +Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а +уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не +пишется. + +#### Scenario: Запись принята + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **AND** отправитель предъявил сессию +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` +- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента +- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и + место под признак повторного файла +- **AND** содержимое записи целиком лежит в хранилище одним файлом +- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии + +#### Scenario: Сессия не даёт учётной записи пользователя + +- **GIVEN** предъявлена сессия владельца панели +- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio` +- **THEN** ответ имеет код `403` +- **AND** ни файла, ни аудиозаписи не заводится + +#### Scenario: Сессии нет + +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии +- **THEN** ответ имеет код `401` +- **AND** ни файла, ни аудиозаписи не заводится +- **AND** тело ответа не несёт данных записи + +#### Scenario: Поля с записью нет + +- **GIVEN** отправитель предъявил сессию +- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio` +- **THEN** ответ имеет код `400` и сообщение об отсутствии записи +- **AND** ни файла, ни аудиозаписи не заводится + +#### Scenario: Размеру записи приём не судья + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **AND** отправитель предъявил сессию +- **WHEN** программа шлёт запись нулевой длины +- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет + +### Requirement: Имя файла в хранилище + +Сервис SHALL сохранять принятую запись под собственным именем — идентификатором, +к которому приписано расширение из имени файла отправителя. Имя, данное +отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и +содержимым своим приёму не подконтрольно. + +Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной +колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до +журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя +подписывает запись». + +Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у +файла в хранилище расширение было всегда. + +Требование переживает смену раскладки. Умолчание хранилища, строящее имя из +имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по +инварианту приватности, а изъятие из него кончается расширением — хвостом после +последней точки. + +#### Scenario: Расширение взято из имени отправителя + +- **WHEN** программа шлёт запись с именем `test.mp3` +- **THEN** имя файла в хранилище оканчивается на `.mp3` + +#### Scenario: Имени без расширения назначено своё + +- **WHEN** программа шлёт запись с именем `test` без расширения +- **THEN** имя файла в хранилище оканчивается на `.audio` + +#### Scenario: Имя отправителя в хранилище не попало + +- **WHEN** программа шлёт запись с именем `секретное-слово.mp3` +- **THEN** имя файла в хранилище не содержит `секретное-слово` +- **AND** путь к этому файлу не содержит его тоже + +### Requirement: Отказ чтения метаданных + +Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать +принятую запись. Ответ MUST иметь код `400`: причина отказа — присланная запись, +а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит +отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит +журналу, а не отправителю. + +Отображение этой ошибки в код и сообщение живёт одним местом на все адреса +приложения; норму держит capability `archive`. + +#### Scenario: Источник метаданных вернул ошибку + +- **GIVEN** источник метаданных не может прочитать запись +- **WHEN** программа шлёт `POST /app/audiorecords` с этой записью +- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку +- **AND** аудиозаписи не заводится + +### Requirement: Имя файла, данное отправителем, не попадает в журнал + +Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную +запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать +текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому +личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, +откуда строку не убрать. + +Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит +один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные +логи. + +Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему +прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, +нормирует требование ниже; наружу расширение выходит только приведённым к +известному виду — этому отдано отдельное требование. + +Оговорка про второй вход из требования ушла вместе с ним: имя, данное +отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и +сценарии судят именно его. + +#### Scenario: Имя записи не видно в журнале принятой записи + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени + несёт опознаваемую строку при обычном расширении `.mp3` +- **THEN** ни одна журнальная запись приёма этой строки не содержит +- **AND** расширение `.mp3` в журнале допустимо + +#### Scenario: Имя записи не видно в журнале при отказе приёма + +- **GIVEN** источник метаданных не может прочитать запись +- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени + несёт опознаваемую строку +- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой + строки не содержит + +### Requirement: Журнал приёма прослеживает запись + +Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой +записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и +удаление имени отправителя прослеживаемости не отнимает. + +Расширение засчитывается собственным полем журнальной строки. Имя, под которым +файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть +ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает +ссылку целиком. Норму держит capability `storage`. + +#### Scenario: Идентификатор, расширение и размер на месте + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **WHEN** программа шлёт `POST /app/audiorecords` с записью +- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение + принятой записи и её размер в байтах + +#### Scenario: Имени файла в хранилище в журнале нет + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **WHEN** программа шлёт `POST /app/audiorecords` с записью +- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет + +## ADDED Requirements + +### Requirement: Имя файла отправителя подписывает запись + +Приём SHALL класть имя файла, данное отправителем, в собственную колонку +аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек +узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт +название, которое дал человек либо посчитала языковая модель, и одной колонкой на +оба смысла посчитанное название затирало бы то, по чему запись узнают, — а +вернуть затёртое было бы неоткуда. + +Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не +сочиняет. + +Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём +MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем +сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель. + +Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет. + +#### Scenario: Имя доходит до записи + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись с именем `разговор.mp3` +- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3` + +#### Scenario: Заголовок принятой записи пуст + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись с именем `разговор.mp3` +- **THEN** колонка заголовка у заведённой записи пуста + +#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки +- **THEN** колонка имени файла несёт имя не длиннее предела +- **AND** управляющих знаков в нём нет + +## REMOVED Requirements + +### Requirement: Опрос готовности задачи + +**Reason**: Адрес опроса отвечал сразу на три вопроса — рубеж записи, время её +заведения и текст расшифровки, — и держать два адреса на один вопрос не за чем. +Карточка записи и её текст читаются теперь порознь: шестичасовая расшифровка +иначе задерживает показ шапки записи на мобильной сети. Вместе с адресом уходят +имена его полей: `job_id` зовётся `id`. + +**Migration**: Рубеж, время заведения и признак остановки берутся карточкой +записи — `GET /app/audiorecords/{id}`, — а текст расшифровки отдельным адресом +`GET /app/audiorecords/{id}/text`. Оба нормирует capability `archive`. +Переносить нечего: стадия проекта — стройка, данных на сервере нет, а внешней +программы на прежнем контракте не существует — своего токена у неё не было. diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-15-app-json-contract/specs/pipeline/spec.md new file mode 100644 index 0000000..d17173c --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/specs/pipeline/spec.md @@ -0,0 +1,95 @@ +## MODIFIED Requirements + +### Requirement: Число отказов ограничивает повторы шага + +У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST +возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост +при захвате, а не при отказе, засчитывает попытку и записи, брошенной на +середине: шаг, уносящий с собой процесс, до объявления отказа не доходит +никогда. + +**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по +собственной остановке сервиса, MUST возвращать число отказов назад и MUST не +выносить записи приговора: запись не виновата в том, что нас перезапустили, и +несколько выкладок подряд иначе останавливают здоровую многочасовую запись с +приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до +объявления исхода, отказ тратит. + +Запись, захваченная с числом отказов сверх заданного предела, MUST +останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в +работу. Остановка эта видна владельцу записи **карточкой записи** наравне с +прочими — норму держит capability `archive`. + +Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое +записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни +с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и +зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую +запись. + +#### Scenario: Запись отказывает на каждой попытке + +- **GIVEN** шаг конвейера отказывает на каждой попытке +- **WHEN** запись проходит заданное число отказов +- **THEN** у неё появляется признак остановки +- **AND** следующий захват её не выдаёт +- **AND** карточка записи отдаёт владельцу признак остановки + +#### Scenario: Шаг уносит процесс, не объявив отказа + +- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке +- **WHEN** запись захватывается снова заданное число раз +- **THEN** у неё появляется признак остановки + +#### Scenario: Остановка сервиса отказа не тратит + +- **GIVEN** шаг работает над записью +- **WHEN** сервис останавливают, и шаг прерывается отменой +- **THEN** число отказов записи прежнее +- **AND** признака остановки у записи не появляется + +#### Scenario: Прошедшая запись отказов не копит + +- **GIVEN** запись прошла подряд несколько рубежей без единого отказа +- **WHEN** смотрят её число отказов +- **THEN** оно не приблизилось к пределу + +### Requirement: Конвейер ответа отправителю не шлёт + +Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться +к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход +своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса; +адрес карточки и содержимое ответа нормирует capability `archive`. + +Держатель нормы сменился вместе с убранным опросом готовности: прежде исход +отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше +нет. Обязанность при этом не изменилась — изменилось только то, каким адресом +она исполняется. + +Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. +Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и +всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за +потерянную ветку. + +Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой +записи — там остановка видна признаком и причиной — и журналом владельца, где у +неё стоит причина. Обязанность при этом сменила направление: прежде об отказе +сообщали, теперь отказ доступен спросившему. Отправитель, который не +спрашивает, об остановке не узнаёт. + +Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, +и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме +хранилища — норму держит capability `storage`. + +#### Scenario: Готовый текст отправителю не уходит + +- **GIVEN** запись дошла до конечного рубежа +- **WHEN** шаг конвейера её завершает +- **THEN** ни одного обращения наружу с текстом расшифровки не уходит +- **AND** текст достаётся отдельным адресом текста записи + +#### Scenario: Остановка видна карточкой, а не сообщением + +- **GIVEN** запись остановлена по исчерпании отказов +- **WHEN** владелец записи спрашивает её карточку +- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину +- **AND** в журнале владельца сервиса есть запись об остановке с причиной diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/specs/storage/spec.md b/openspec/changes/archive/2026-08-15-app-json-contract/specs/storage/spec.md new file mode 100644 index 0000000..f70c713 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/specs/storage/spec.md @@ -0,0 +1,104 @@ +## MODIFIED Requirements + +### Requirement: Аудиозапись — центральная сущность хранилища + +Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней +приложено, — отдельными строками со ссылками с записи. Приложениями считаются +файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания. + +Поля, которыми распоряжается очередь — признак захвата, срок его протухания, +пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым +записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня +расшифровка лежит колонкой той же строки и читается при каждом захвате. + +Запись MUST нести заголовок и краткое описание своими колонками: они читаются +вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать +отдельными строками: они читаются по открытию одной записи. + +Тем же доводом запись MUST нести своими колонками **имя файла, данное +отправителем, длительность и размер**. Все три показываются в списке. Приём +узнаёт длительность и размер у источника метаданных и так, а имя файла приходит +вместе с записью. + +**Имена колонок и единицы измерения нормативны:** `original_filename`, +`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени, +а не в комментарии: шаг схемы применённым не переписывается, а расхождение +«секунды против миллисекунд» между колонкой, ответом списка и объявленным +пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны +потому, что этой единицей уже названы соседние колонки схемы. + +**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не +недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое +кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой +колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе +величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не +удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает +ноль. Решение владельца 2026-08-15. + +Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок +несёт название, которое дал человек либо посчитала языковая модель; имя файла — +то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба +смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. + +Величины на записи и на её файле расходятся по смыслу, и **равенство между ними +не поддерживается никем — намеренно**. На записи лежит снимок **принятого**, +взятый приёмом один раз и больше не пересчитываемый; на файле — величины той +копии, которой файл является сейчас. Приведённая копия имеет свой размер, и +записи он не принадлежит. + +Отсюда норма, без которой два числа читались бы как копии одного: величины +записи MUST не сверяться со строкой файла и MUST не переписываться ничем после +приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек +прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные, +сменили источник, нарезали длинную запись — меняет вторую величину и не трогает +первую. + +#### Scenario: Список читается без содержимого + +- **GIVEN** у записи есть расшифровка +- **WHEN** читают запись ради её рубежа и заголовка +- **THEN** текст расшифровки при этом не читается + +#### Scenario: Длительность и размер читаются без строки файла + +- **GIVEN** запись принята +- **WHEN** читают её длительность и размер +- **THEN** строка файла при этом не читается + +#### Scenario: Посчитанный заголовок не затирает имя файла + +- **GIVEN** запись принята с именем файла отправителя +- **WHEN** записи проставляют заголовок +- **THEN** имя файла остаётся прежним + +### Requirement: Тексты и структура лежат отдельно от записи + +Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом +текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не +хранить их колонками. + +Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на +каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится +за каждую такую колонку отдельно. + +**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре +«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый +ответ несколькими операциями и только потом двигает рубеж: прерванный на середине +и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос +«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния. + +Потребитель текста MUST называть **вид**, который берёт, а не брать последний +записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит +приложению, называет вид запросом — норму держит capability `archive`. + +#### Scenario: Расшифровка лежит своей строкой + +- **GIVEN** запись прошла распознавание +- **WHEN** смотрят, где лежит текст расшифровки +- **THEN** он лежит отдельной строкой, на которую запись ссылается + +#### Scenario: Повтор шага не заводит второй расшифровки + +- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа +- **WHEN** шаг повторяется с прежнего рубежа +- **THEN** строка расшифровки у записи одна diff --git a/openspec/changes/archive/2026-08-15-app-json-contract/tasks.md b/openspec/changes/archive/2026-08-15-app-json-contract/tasks.md new file mode 100644 index 0000000..8dbea0c --- /dev/null +++ b/openspec/changes/archive/2026-08-15-app-json-contract/tasks.md @@ -0,0 +1,189 @@ +## 1. Схема и модель записи + +- [x] 1.1 Завести шаг схемы под три колонки записи: `original_filename`, + `duration_ms`, `size_bytes`. Один шаг на все три; применённый шаг не + переписывается. Единица стоит в имени колонки +- [x] 1.2 Добавить три поля в `entity.AudioRecord` с комментарием о разрезе + «имя файла против заголовка»; неизвестную длительность отличить от нулевой +- [x] 1.3 Провести колонки через оба места правки в пакете хранилища: + `applyToRecord` кладёт их (это заведение), `applyOwnedByPipeline` — не трогает + (конвейер их не меняет), `recordToAudioRecord` читает +- [x] 1.4 Добавить темы записи в модель и в чтение записи; в запись их не кладёт + ни приём, ни конвейер +- [x] 1.5 Прогнать `schema_test.go` и сверку правил `internal/archrules` + +## 2. Приём: имя файла, длительность и размер + +- [x] 2.1 Завести предел длины имени отправителя и уборку управляющих знаков + одним местом в домене; предел назвать константой с обоснованием +- [x] 2.2 Записать в принимаемую запись очищенное имя, длительность и размер; + колонку заголовка оставить пустой +- [x] 2.3 Проверить тестом, что имя отправителя не появилось ни в имени файла + хранилища, ни в журнале — прежние тесты приватности остаются зелёными + +## 3. Отображение доменной ошибки в ответ + +- [x] 3.1 Завести доменный признак «присланная запись негодна» и обернуть им + отказ источника метаданных в службе приёма +- [x] 3.2 Написать единую функцию отображения доменной ошибки в код ответа и + человекочитаемое сообщение по таблице из `docs/conventions/errors.md` +- [x] 3.3 Завести единую форму тела отказа и провести через неё все ветви: + ненайденная запись, негодный ввод, отсутствие учётной записи, сбой хранилища +- [x] 3.4 Завести доменный признак «запись сверх потолка размера», код `413`, + предел в теле числом +- [x] 3.5 Убрать опечатку `transcibe` из текста ошибки приёма +- [x] 3.6 Завести закрытый перечень машиночитаемых кодов отказа и отдавать их + вторым полем тела рядом с сообщением человеку +- [x] 3.7 Назвать точку отображения в `docs/architecture.md`, «Единые точки + проекта», и снять расхождение в `docs/conventions/errors.md` +- [x] 3.8 Дописать в таблицу `docs/conventions/errors.md` ветви, которых там нет: + `401`, `403`, `413` и машиночитаемый код отказа — объявленный источник единой + точки иначе расходится с ней в первый же день + +## 4. Пространство адресов приложения + +- [x] 4.1 Перевесить группу маршрутов с чужого корня на `/app/`, слой сессии + повесить на группу корня, а не на перечень адресов +- [x] 4.2 Завести своё правило ограничителя частоты под корень приложения +- [x] 4.3 Убрать прежние адреса `POST /api/audio` и `GET /api/status/{id}` + целиком + +## 5. Адреса чтения + +- [x] 5.1 `GET /app/me` — идентификатор учётной записи и имя к показу, без + адреса почты +- [x] 5.2 `GET /app/config` — потолок размера тем же числом, каким сервис + ограничивает тело запроса, частота опроса, перечень известных расширений, + потолок тем +- [x] 5.3 `GET /app/audiorecords` — страница своих записей новыми сверху, с + ключом следующей страницы и общим числом; отбор тремя состояниями +- [x] 5.4 `GET /app/audiorecords/{id}` — карточка записи без текста, с перечнем + доступных видов текста `available_views`; пустой перечень значит «текста ещё + нет» +- [x] 5.5 `GET /app/audiorecords/{id}/text` — текст названного вида из закрытого + перечня `transcript`, `literary`, `replicas`; неизвестный и незаданный вид + дают `400`, отсутствующий у записи вид — `409` +- [x] 5.6 `POST /app/audiorecords` — ответ списком с местом под признак + повторного файла, элемент той же формы, что и карточка + +## 6. Отбор и чтение в хранилище + +- [x] 6.1 Добавить в репозиторий записей выборку по владельцу курсором на паре + «время заведения и идентификатор», новыми сверху, с умолчанием и потолком + размера страницы +- [x] 6.2 Проверить тестом, что выборка списка не читает ни строки текста, ни + структуры реплик +- [x] 6.3 Разрешить темы записи названиями одним запросом на страницу, а не по + запросу на запись +- [x] 6.4 Вывести предикаты трёх состояний отбора («в работе», «остановлена», + «прошла конвейер») из дескриптора рубежа в `internal/entity`, а не строкой + фильтра в репозитории +- [x] 6.5 Дописать правило `internal/archrules` на нового потребителя словаря + рубежей — отбор списка + +## 7. Документация и гейт + +- [x] 7.1 `docs/database.md` — три новые колонки записи +- [x] 7.2 `docs/conventions/web-ui.md` — правило неизвестного пути перечисляет + корни сервиса (проверить, что запись уже верна) +- [x] 7.3 `CLAUDE.md`, инвариант «Принятая запись не теряется молча» — причина + видна владельцу **карточкой записи**, а не опросом готовности: опрос убран, и + инвариант ссылается на адрес, которого нет +- [x] 7.4 `docs/passport.md` — сценарии потребителей называют `POST /api/audio` и + `GET /api/status/:id` действующими; задача `api-tokens` прочитает их как + действующие и заведёт токен на несуществующий адрес +- [x] 7.5 `docs/security.md` — периметр и таблица поверхностей перечисляют + убираемые адреса и не знают ни одного адреса приложения, а изменение добавляет + их несколько +- [x] 7.6 `docs/architecture.md` — строка компонента «HTTP API» и строка «кто + заметит отказ» называют опрос готовности; там же строка про `done` без текста +- [x] 7.7 Обновить мутационную проверку `journal_route_test.go`: пути `/api/audio` + и `/api/status/...` в ней заменены новыми адресами приложения +- [x] 7.8 `task gate` зелёный целиком +- [x] 7.9 Поднять сервис локально: шаг схемы применён на чистой базе, адреса + приложения подняты, отказ без сессии идёт единой формой, прежние адреса + отвечают `404`. Сценарий вошедшего локально не проходится по устройству + проекта: сессию выдаёт только провайдер OIDC, а локальный запуск наружу не + ходит — этот путь закрыт машиной в тестах, через настоящий роутер + +## Критерии приёмки + +Два источника, и приёмка судится по одному списку. Первый блок — из записи +задачи `json-api-for-spa` дословно: файл задачи закрытие удалит, критерии +обязаны его пережить. Второй — рубрика ревью дизайна, дополняющая их там, где +постановка молчала. + +### От постановки + +- Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при + чтении даёт `500`, а не `404`, негодный файл — `400` с человекочитаемым + текстом, а не `500`, а отказ приёма по пустому владельцу — `403`, как и отказ + слоя предъявления. Тело ошибки при этом одной формы на всех адресах, без + сырого `err.Error()` и без опечатки `transcibe`, а отображение живёт в одной + функции, названной в `docs/architecture.md`. Оракул — три теста на код + (репозиторий с ошибкой драйвера; файл, который отвергает разбор метаданных; + вызов приёма без учётной записи), тест формы на четырёх ветвях отказа, пустой + `grep -rn 'transcibe' internal/` и `task gate`, шаг `docs.py check`. +- Список отдаётся страницами, новые сверху, и несёт заголовок, имя файла + отправителя, длительность, размер, рубеж с признаком остановки и темы, не + читая при этом ни расшифровки, ни структуры реплик. Оракул — два теста: + выборка больше страницы; запись с расшифровкой, у которой чтение списка не + трогает строку текста. +- Ответ приёма отдаёт список заведённых записей и место под признак повторного + файла, даже когда файл в запросе один, а имя файла отправителя лежит своей + колонкой — обрезанное по пределу и без управляющих знаков — при пустом + заголовке. Оракул — четыре теста приёма: тело ответа — список из одного + элемента с полем признака повтора; имя `разговор.mp3` доходит до + `original_filename`; колонка заголовка у принятой записи пуста; имя длиннее + предела и с управляющими знаками доходит обрезанным и очищенным. +- `GET /app/config` отдаёт потолок размера тем же числом, каким сервер отвергает + запись сверх него, а не своей копией. Оракул — тест: значение в ответе равно + `entity.MaxRecordSize`, и подмена константы меняет ответ. +- Карточка записи и её текст читаются порознь, а прежний опрос готовности + отвечает `404`: карточка несёт перечень доступных видов текста, а текст + отдаётся названным видом. Оракул — четыре теста: карточка без поля текста, но с + перечнем доступных видов; текст вида `transcript`; текст вида `replicas`; + прежние адреса `GET /api/status/{id}` и `POST /api/audio` на заведённой записи + отвечают `404`. + + *Правка от 2026-08-15:* критерий приведён к спеке после повторной сверки. + Прежняя формулировка требовала признак наличия текста и «тот же вид репликами» + — обе воскрешали то, что ревью дизайна уже закрыло: булев признак не выражает + состояния «текст есть, реплик нет», а `transcript` репликами не отдаётся, у них + своё значение перечня. + +### От рубрики ревью дизайна + +- Страница задаётся непрозрачным ключом на паре «время заведения и + идентификатор», и запись, заведённая между двумя страницами, окна не сдвигает. + Оракул — два теста: прочитать первую страницу, завести запись, прочитать + следующую ключом — ни повторов, ни пропусков; две записи с одинаковым временем + заведения приходят в одном и том же порядке при повторном запросе. +- Карточка и элемент страницы — одна форма, а элемент ответа приёма равен + карточке плюс признак повтора. Оракул — два теста: набор полей карточки + совпадает с набором полей элемента страницы, кроме `available_views`; набор + полей элемента ответа приёма равен набору полей карточки плюс `duplicate`. +- Размер страницы: умолчание применяется, значение сверх потолка усекается, + негодное отвергается. Оракул — три теста: запрос без размера; запрос размером + сверх потолка; запрос размером ноль даёт `400`. +- Отбор различает три состояния, и остановленная запись приходит ровно в одном + из них. Оракул — тест на трёх записях: в работе, остановленная, прошедшая + конвейер; каждая приходит ровно один раз. +- «Текста этого вида ещё нет» отличимо от «записи нет». Оракул — тест: запись с + расшифровкой и без структуры на вид `replicas` даёт `409`, а неизвестный + идентификатор — `404`. +- Отказ по превышению потолка размера проходит через единую форму. Оракул — + тест: тело сверх `entity.MaxRecordSize` даёт `413`, а тело ответа несёт код + отказа, сообщение и предел числом. +- Колонки названы с единицей в имени. Оракул — шаг схемы заводит `duration_ms` и + `size_bytes`; тест приёма проверяет, что обе величины доходят до записи. + + *Правка от 2026-08-15:* требование «неизвестная длительность отличима от + нулевой» снято решением владельца после ревью кода. Числовая колонка хранилища + пустого значения не держит — проверено прогоном, — а платить за отличимость + четвёртой колонкой или текстовым типом не за что: обе величины ставит приём и + ставит всегда. +- Отсутствие сессии не даёт различить заведённую запись и неизвестную. Оракул — + тест: оба запроса без сессии дают `401` с одним телом. +- Карточка остановленной записи несёт рубеж, признак остановки и её причину, а + машинного текста отказа не несёт. Оракул — тест на остановленной записи. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 9a74108..6d17ad0 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -149,15 +149,19 @@ MUST не заводить: учётные записи держит прова заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной кукой получал бы не то, что предъявил на собственных адресах хранилища. -Область действия слоя MUST быть ограничена адресами приложения — приёмом записи -и опросом готовности. Собственная поверхность хранилища под него не подпадает: -часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и -расширение слоя на всё сняло бы эту защиту молча. +Область действия слоя MUST быть ограничена **адресами приложения** — теми, что +живут под его собственным корнем. Собственная поверхность хранилища под него не +подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не +шлёт, и расширение слоя на всё сняло бы эту защиту молча. + +Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым +адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия, +предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы. #### Scenario: Кука открывает доступ - **GIVEN** человек вошёл и получил куку сессии -- **WHEN** он шлёт запрос к API с этой кукой и без заголовка +- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка - **THEN** запрос проходит #### Scenario: Кука защищена от чтения скриптом @@ -170,6 +174,12 @@ MUST не заводить: учётные записи держит прова - **WHEN** запрос несёт и куку сессии, и заголовок `Authorization` - **THEN** проверку проходит значение заголовка, а не куки +#### Scenario: Слой не расширяется на поверхность хранилища + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой +- **THEN** значение куки в заголовок не перекладывается + ### Requirement: Значение, дающее доступ, не печатается Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии, @@ -362,10 +372,11 @@ MUST не делать. Кто допущен, определяет правил имя. Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей. -Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице +Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице ответов считывается, какие записи заведены, а идентификатор записи и есть то, что разграничение прячет. Каким именно ответом это выражено, нормирует -capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один. +capability `archive`: там живут адреса чтения записи, и держатель нормы обязан +быть один. Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны **спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище @@ -377,13 +388,13 @@ capability `intake`: там живёт адрес опроса, и держат #### Scenario: Своя запись доступна - **GIVEN** человек вошёл и принял запись -- **WHEN** он спрашивает состояние этой записи своей сессией -- **THEN** ответ несёт состояние записи +- **WHEN** он спрашивает карточку этой записи своей сессией +- **THEN** ответ несёт данные записи #### Scenario: Чужая запись неотличима от несуществующей - **GIVEN** запись принята одним вошедшим -- **WHEN** её состояние спрашивает другой вошедший +- **WHEN** её карточку спрашивает другой вошедший - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом #### Scenario: Владельца не задают запросом @@ -399,5 +410,39 @@ capability `intake`: там живёт адрес опроса, и держат #### Scenario: Пустой владелец не открывает ничего - **GIVEN** заведены две записи: своя и чужая -- **WHEN** состояние каждой спрашивают с пустым владельцем +- **WHEN** карточку каждой спрашивают с пустым владельцем - **THEN** ответ на обе тот же, что и на неизвестный идентификатор + +### Requirement: Приложение узнаёт вошедшего + +Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и +MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом, +которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает +разметку само и вошедшего узнаёт ответом. + +Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не +может вовсе — этот адрес единственный способ его узнать. + +Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями +`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и +принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает +ему выходить наружу наравне с журналом. + +#### Scenario: Вошедший узнан + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** приложение спрашивает, кто вошёл +- **THEN** ответ несёт идентификатор его учётной записи + +#### Scenario: Сессии нет + +- **WHEN** приложение спрашивает, кто вошёл, без сессии +- **THEN** ответ имеет код `401` +- **AND** тело ответа не несёт учётной записи + +#### Scenario: Адреса почты в ответе нет + +- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты +- **WHEN** приложение спрашивает, кто вошёл +- **THEN** адреса почты в ответе нет + diff --git a/openspec/specs/archive/spec.md b/openspec/specs/archive/spec.md new file mode 100644 index 0000000..ab4b84b --- /dev/null +++ b/openspec/specs/archive/spec.md @@ -0,0 +1,470 @@ +# archive Specification + +## Purpose +TBD - created by archiving change app-json-contract. Update Purpose after archive. +## Requirements +### Requirement: Адреса приложения живут своим пространством + +Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не +занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно +вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он +литерал библиотеки, а не настройка. + +Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся: +обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они +молча — тем же адресом начнёт отвечать не тот обработчик. + +Цена переезда называется здесь же. Правило неизвестного пути, по которому +приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не +один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель +частоты хранилища настроен на чужой корень и наших адресов больше не покрывает, +поэтому сервис MUST заводить своё правило под корень приложения. + +Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность +и выключен умолчанием, поэтому включение нашего правила вводит в действие и его +собственные — на входе, на заведении записей и на его адресах. Принимается +сознательно: без включения наше правило не значит ничего. + +Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки +человека живут под корнем приложения. + +#### Scenario: Адрес приложения отвечает под своим корнем + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает список своих записей под корнем приложения +- **THEN** ответ приходит от сервиса, а не от хранилища + +#### Scenario: Прежние адреса приложения не отвечают + +- **GIVEN** заведена запись +- **WHEN** её спрашивают прежними адресами в чужом пространстве +- **THEN** ответ имеет код `404` + +#### Scenario: Ограничитель частоты покрывает адреса приложения + +- **WHEN** сервис поднялся +- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем + приложения + +### Requirement: Отказ называет причину, а не место + +Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине** +отказа, а не месту, где он случился. Перечень закрыт и назван поимённо: + +- отсутствие сессии — `401`, и он MUST наступать **до всякого чтения записи**, + одинаково для заведённой записи и для неизвестного идентификатора: иначе по + разнице кодов перебирается список заведённых записей; +- узнанный предъявитель без учётной записи пользователя — `403`; +- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST + отвечать чужая и ничья запись; +- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, + негодный размер страницы; +- запись сверх потолка размера — `413`, и тело MUST нести предел числом; +- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у + записи ещё нет; +- отказ хранилища и всякая неназванная причина — `500`. + +Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все +адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места +нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую +базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как +«моя запись пропала», а второе не говорит ему ничего. + +Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** +поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное +человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы +различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» — +все три `400`, — а приложению надо решать, предлагать ли повтор и что показать +человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же +задача экрана переписала бы контракт, согласованный здесь один раз. + +Имена полей и перечень кодов нормативны — их разбирает каждый экран, и +выбранные кодом они стали бы контрактом молча: + +- поля тела: `error_code` и `message`; +- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`, + `bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`. + +Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, +неизвестный путь под корнем приложения, — и до отображения доменной ошибки не +доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на +адресах приложения две, а самый частый отказ у человека на мобильной сети — +«запись больше потолка» — приходит телом библиотеки, без кода и без предела +числом. + +Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа +заводится добавлением в него, а не строкой в обработчике: иначе ветвь по +умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в +журнале аварию там, где её нет. + +Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали +устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка +остаётся в журнале владельца сервиса. + +#### Scenario: Сбой хранилища виден как сбой + +- **GIVEN** хранилище отвечает отказом драйвера на чтение записи +- **WHEN** владелец спрашивает свою запись +- **THEN** ответ имеет код `500` +- **AND** тела записи в ответе нет + +#### Scenario: Негодная запись видна как негодная + +- **GIVEN** источник метаданных не может прочитать присланную запись +- **WHEN** отправитель шлёт её приёмом +- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку +- **AND** причина отказа в тело ответа не попадает + +#### Scenario: Отказ по пустому владельцу + +- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет +- **WHEN** он шлёт запись приёмом +- **THEN** ответ имеет код `403` + +#### Scenario: Форма тела одна на всех ветвях отказа + +- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по + отсутствию учётной записи и по сбою хранилища +- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же + полями +- **AND** код отказа принадлежит закрытому перечню +- **AND** ни одно из них не содержит сырого текста ошибки + +#### Scenario: Без сессии неизвестная запись неотличима от заведённой + +- **GIVEN** заведена запись +- **WHEN** её карточку спрашивают без сессии, а затем спрашивают карточку по + неизвестному идентификатору +- **THEN** оба ответа имеют код `401` и одно тело + +#### Scenario: Запись сверх потолка размера + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись длиннее потолка размера +- **THEN** ответ имеет код `413`, а тело несёт предел числом +- **AND** ни файла, ни аудиозаписи не заводится + +### Requirement: Сервис объявляет свои пределы + +Сервис SHALL отдавать свои пределы отдельным адресом — `GET /app/config` — и +MUST называть в нём потолок размера одной записи, потолок размера страницы, +частоту опроса карточки, перечень известных расширений и потолок числа тем у +записи. + +Предел зовётся частотой опроса **карточки**, а не готовности: адрес опроса +готовности это же изменение убирает целиком, и читатель через месяц искал бы то, +чего нет. + +Имена полей ответа нормативны: `max_record_size_bytes`, `max_page_size`, +`poll_interval_ms`, `known_extensions`, `max_topics_per_record`. + +**Каждый объявленный предел MUST быть тем же значением, которое сервис +применяет, а не его копией.** Правило общее, а не про один потолок размера: +приложение, знающее предел своей константой, расходится с сервером молча — до +первого отказа на записи, которую человек уже успел отправить по мобильной сети. +Ровно то же случается, когда предел объявлен сервером, но взят из второй +константы рядом с применяемой. + +Отсюда источник у каждого: + +- потолок размера записи — то число, которым сервис ограничивает тело запроса + приёма и отвергает запись кодом `413`; +- потолок числа тем у записи — то число, которым его ограничивает схема + хранилища; норму держит capability `storage`; +- потолок размера страницы — то число, до которого сервис усекает запрошенный + размер страницы; +- частота опроса — выводится из **доли** бюджета ограничителя частоты под корнем + приложения и MUST не задаваться своей константой. Доля, а не весь бюджет: + опрос идёт не один — в ту же секунду приложение листает список, открывает + соседнюю карточку и грузит новую запись, а бюджет один на все адреса + приложения и считается по адресу спрашивающего, а не по учётной записи. + Объявленная частота, равная всему бюджету, отдавала бы отказ на любом втором + запросе — тот самый, которого объявление обещает избежать. Иначе приложение, + честно опрашивающее карточку с объявленной частотой, упирается в собственный + ограничитель сервиса — и получает отказ, которого сервис сам же ему и обещал + избежать; +- перечень известных расширений — тот же, что сужает метку метрики, за вычетом + собственного умолчания сервиса `audio`: оно не формат, и подсказкой человеку + выходить не должно. Второй перечень рядом с первым разошёлся бы с ним молча. + +**Перечень известных расширений — исключение в другом: сервис по нему не +судит.** Приём о годности записи не судит сам — расширение он берёт из +имени файла, а пригодность содержимого узнаёт у источника метаданных, — и +перечень служит приложению подсказкой для диалога выбора файла, не более. +Умолчать об этом нельзя: приложение прочитало бы перечень как «что можно +загружать» и отвергало бы запись, которую сервис принял бы и расшифровал. + +Адрес MUST быть доступен тому же, кому доступны прочие адреса приложения: +пределы не тайна, но отдельного открытого адреса ради них не заводится. + +#### Scenario: Потолок размера равен тому, которым сервис отвергает + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает пределы сервиса +- **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает + тело запроса приёма + +#### Scenario: Потолок страницы равен применяемому + +- **WHEN** человек спрашивает пределы сервиса, а затем просит страницу размером + сверх объявленного потолка +- **THEN** размер отданной страницы не превышает объявленного потолка + +### Requirement: Страница своих записей + +Сервис SHALL отдавать владельцу страницу его записей — `GET /app/audiorecords` — +новыми сверху, и MUST не показывать в ней ни одной чужой записи. Спрашивающий с +пустым именем MUST не получать ни одной записи. + +Ответ MUST нести страницу, ключ следующей страницы и общее число записей. Число +записей одного человека растёт годами — сервис объявлен архивом, — и ответ без +страниц перестал бы помещаться в память телефона. + +**Страница задаётся ключом, а не номером.** Приём пишет в голову той же ленты, +которую читает список, и человек, загрузивший запись и листающий свой архив, — +штатный сценарий, а не редкость. Номер страницы сдвинул бы окно на единицу: +последний элемент первой страницы пришёл бы вторым разом первым элементом второй, +а один элемент между ними не пришёл бы никогда. Отказ молчаливый — ни кода, ни +строки в журнале, — и человек видел бы архив, в котором записи нет. + +Ключ MUST быть непрозрачным для спрашивающего и MUST задавать положение +**полным** ключом сортировки — парой «время заведения и идентификатор». Одного +времени мало: у записей, принятых одним запросом, оно совпадает, и порядок между +ними иначе не определён вовсе. + +Ключа следующей страницы нет — страница последняя; пустая страница MUST отвечать +успехом, а не отказом: отсутствие записей не есть ошибка. + +Ключ, который сервис не может прочитать — протухший, обрезанный, подделанный, — +MUST давать отказ по негодному вводу. Молчаливая отдача первой страницы вместо +этого дала бы человеку архив, листающийся по кругу, и ни строки в журнале. + +**Сторона запроса нормируется наравне со стороной ответа.** У размера страницы +MUST быть умолчание и потолок; размер сверх потолка MUST усекаться до него, а не +отвергаться, а негодное значение — ноль, отрицательное, нечисловое — MUST давать +отказ по негодному вводу. Незаданный потолок был бы способом попросить весь архив +одним запросом, то есть обойти постраничность тем самым параметром, ради которого +она заведена. + +Имена полей ответа и элемента нормативны: экраны строятся на них, и +переименование после того, как экран написан, стоит правки приложения. + +- параметры запроса: `cursor`, `limit`, `filter`; +- страница: `items`, `next_cursor`, `total_items`; +- элемент: `id`, `title`, `original_filename`, `brief`, `topics`, `state`, + `halted`, `halt_reason`, `duration_ms`, `size_bytes`, `created_at`. + +Значение `state` MUST принадлежать перечню рубежей конвейера, а `halt_reason` — +перечню причин остановки. Оба перечня объявлены одним местом, и перечислять их +порознь в потребителе нельзя: рубеж, добавленный конвейером, иначе разошёлся бы +с ответом молча. + +Чтение страницы MUST не читать ни расшифровки, ни структуры реплик: обе лежат +порознь от записи ровно затем, чтобы список их не тянул. Длительность и размер +MUST браться колонками самой записи, а не строкой её файла. + +Машинный текст отказа MUST в элемент страницы не попадать: он принадлежит +журналу владельца сервиса. Причина остановки — значение из закрытого перечня, и +она не он. + +Значения причины остановки этим требованием впервые выходят в публичный ответ, и +это осознанно: без причины признак остановки не говорит человеку, чего ждать — +повтора, своего действия или ничего. Превращает значение в русскую фразу +**приложение**, а не сервис: сервис отдаёт значение перечня, и второй словарь +фраз на стороне сервера разошёлся бы с тем, что показывает экран. + +**Отбор MUST различать три состояния, а не два:** запись в работе (`working`), +запись остановлена (`halted`), запись прошла конвейер (`done`). Незаданный отбор +значит «все». Двух значений не хватает: остановленная +запись не в работе и не завершена, и при отборе надвое она выпала бы из обеих +половин — то есть исчезла бы из списка при любом значении отбора, хотя ради неё +человек список и открывает. Предикат каждого состояния MUST выводиться из +дескриптора рубежа и признака остановки, а не перечислять рубежи строкой запроса: +рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх. + +#### Scenario: Страница отдаётся новыми сверху + +- **GIVEN** владелец завёл записей больше, чем помещается на страницу +- **WHEN** он спрашивает первую страницу +- **THEN** в ней лежит ровно столько записей, сколько вмещает страница +- **AND** первой стоит заведённая последней +- **AND** ответ несёт общее число его записей и ключ следующей страницы + +#### Scenario: Запись, заведённая между страницами, окна не сдвигает + +- **GIVEN** владелец прочитал первую страницу и взял ключ следующей +- **WHEN** он заводит новую запись и спрашивает следующую страницу этим ключом +- **THEN** ни один элемент первой страницы в ней не повторяется +- **AND** ни одна запись между страницами не пропущена + +#### Scenario: Записи с одним временем заведения идут в устойчивом порядке + +- **GIVEN** две записи заведены одним запросом и время заведения у них совпадает +- **WHEN** владелец читает страницу дважды +- **THEN** порядок этих записей в обоих ответах один и тот же + +#### Scenario: Последняя страница + +- **WHEN** владелец дочитал архив до конца +- **THEN** ответ имеет код `200`, а ключа следующей страницы в нём нет + +#### Scenario: Чужих записей в странице нет + +- **GIVEN** записи заведены двумя вошедшими +- **WHEN** страницу спрашивает один из них +- **THEN** в ней лежат только его записи + +#### Scenario: Список не тянет расшифровку + +- **GIVEN** у записи есть расшифровка +- **WHEN** владелец спрашивает страницу своих записей +- **THEN** текста расшифровки в ответе нет +- **AND** чтение страницы строку текста не трогает + +#### Scenario: Размер страницы сверх потолка усекается + +- **WHEN** владелец просит страницу размером больше объявленного потолка +- **THEN** ответ имеет код `200`, а размер страницы равен потолку + +#### Scenario: Негодный размер страницы отвергается + +- **WHEN** владелец просит страницу размером ноль либо нечисловым значением +- **THEN** ответ имеет код `400` + +#### Scenario: Остановленная запись видна отбором + +- **GIVEN** у владельца есть запись в работе, остановленная запись и прошедшая + конвейер +- **WHEN** он спрашивает каждое из трёх состояний отбором +- **THEN** каждая запись приходит ровно в одном из них +- **AND** остановленная приходит с признаком остановки и её причиной + +### Requirement: Карточка записи отдаётся без текста + +Сервис SHALL отдавать владельцу карточку одной записи — `GET +/app/audiorecords/{id}` — и MUST не класть в неё текста расшифровки. + +**Карточка несёт те же поля, что и элемент страницы, плюс перечень доступных +видов текста** — полем `available_views`. Две формы одной вещи разошлись бы +молча, поэтому состав задан одной нормой, а не двумя. + +Отсюда обязанность, которой держится инвариант проекта «принятая запись не +теряется молча»: карточка MUST нести рубеж, признак остановки и её причину. +Прежде исход своей записи владелец узнавал опросом готовности; опрос убран, и +единственным местом, где отправитель узнаёт о неудаче, становится карточка. Норма +эта переехала сюда целиком — capability `pipeline` называет держателем её этот +адрес. + +Вид считается доступным по **содержимому**, а не по наличию ссылки на текст. +Ссылка без содержимого — состояние штатное: пустой ответ распознавания сервис +признаёт нормой и записывает его в журнал. Строй мы перечень по ссылкам, +карточка объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» +вечно — приложение опрашивало бы его без конца, а человек видел бы завершённую +запись, из которой текст «вот-вот появится». + +Перечень MUST присутствовать в ответе **всегда**, в том числе пустым: отсутствие +поля и пустой перечень приложение не различит, а значат они разное. + +Перечень доступных видов MUST быть **перечнем**, а не признаком «текст есть». +Видов больше одного, и шаг завершения пишет их несколькими операциями: состояние +«сплошной текст есть, реплик ещё нет» достижимо. Один признак на несколько видов +отправил бы приложение за репликами, которых нет, — и исход стал бы функцией +того, в каком месте прервался шаг, а не состояния записи. Пустой перечень значит +«текста ещё нет». + +Шестичасовая расшифровка, приехавшая вместе с шапкой записи, задерживает показ +на мобильной сети на то время, которое человеку не нужно ждать: шапку он читает +сразу, а текст — если решил читать. + +Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный +идентификатор. + +#### Scenario: Карточка без текста + +- **GIVEN** у записи есть расшифровка +- **WHEN** владелец спрашивает её карточку +- **THEN** поля с текстом в ответе нет +- **AND** перечень доступных видов несёт сырую расшифровку + +#### Scenario: Остановленная запись видна карточкой + +- **GIVEN** запись остановлена признаком по исчерпании отказов +- **WHEN** владелец спрашивает её карточку +- **THEN** карточка несёт достигнутый рубеж, признак остановки и её причину +- **AND** машинного текста отказа в ответе нет + +#### Scenario: Текста ещё нет + +- **GIVEN** запись не дошла до расшифровки +- **WHEN** владелец спрашивает её карточку +- **THEN** перечень доступных видов пуст + +#### Scenario: Чужая карточка неотличима от неизвестной + +- **GIVEN** запись заведена одним вошедшим +- **WHEN** её карточку спрашивает другой вошедший +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +### Requirement: Текст записи отдаётся названным видом + +Сервис SHALL отдавать текст записи отдельным адресом — `GET +/app/audiorecords/{id}/text` — и MUST отдавать **вид, названный спрашивающим**. +Отдача «последнего записанного» сделала бы ответ функцией порядка записи, а не +состояния записи. + +**Перечень видов закрыт, и каждое его значение называет ровно одну хранимую +вещь:** + +- `transcript` — сырая расшифровка сплошным текстом; +- `literary` — вычитанный текст сплошным; +- `replicas` — реплики со временем. + +Перечень назван так, а не парой «вид текста плюс форма показа», потому что +реплики со временем — не вид текста: они лежат структурой разбора и принадлежат +записи, а не тексту. Пара из двух параметров обещала бы сочетания, которых не +существует. + +Вычитанный текст назван здесь, хотя считает его отдельная задача: перечень, +заведённый без него, пришлось бы расширять правкой публичного контракта — того +самого, который согласуется здесь один раз. До появления вычитанного текста +значение просто не встречается в перечне доступных видов у карточки. + +Значения `transcript` и `literary` MUST совпадать с видами текста, объявленными +хранилищем: два словаря об одном разошлись бы молча. + +Текста запрошенного вида нет — сервис MUST отвечать кодом `409`, а не пустой +строкой и не `404`. Пустая строка читается как «расшифровка пуста»; `404` слился +бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на +своей записи, загруженной минуту назад, — ровно тот отказ, ради устранения +которого заводится весь контракт. + +Вид, которого сервис не знает, и незаданный вид MUST давать отказ по негодному +вводу: умолчание сделало бы ответ функцией того, что успел записать конвейер. + +Текст чужой записи MUST быть недоступен наравне с её карточкой. + +#### Scenario: Сырая расшифровка сплошным текстом + +- **GIVEN** у записи есть сырая расшифровка +- **WHEN** владелец спрашивает её текст видом `transcript` +- **THEN** ответ несёт содержимое сырой расшифровки + +#### Scenario: Реплики со временем + +- **GIVEN** у записи есть структура реплик +- **WHEN** владелец спрашивает её текст видом `replicas` +- **THEN** ответ несёт реплики, и у каждой стоит её время + +#### Scenario: Текста этого вида ещё нет + +- **GIVEN** у записи есть сырая расшифровка и нет структуры реплик +- **WHEN** владелец спрашивает её текст видом `replicas` +- **THEN** ответ имеет код `409` +- **AND** он отличается от ответа на неизвестный идентификатор + +#### Scenario: Вид неизвестен или не назван + +- **WHEN** владелец спрашивает текст видом, которого сервис не знает, либо не + называет вида вовсе +- **THEN** ответ имеет код `400` + diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 27b4478..44d3ff2 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -12,21 +12,43 @@ ## Requirements ### Requirement: Приём записи по HTTP -Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с -телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**. +Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом +`multipart/form-data` и полем `audio` **только от узнанного отправителя**. Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и -получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести -идентификатор записи полем `job_id` и её рубеж полем `status`. +получить заведённую под неё аудиозапись на рубеже `uploaded`. -Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень -состояний назван проектом необратимым, и ломка объявлена прямо — состояние -теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет -вовсе. +Приём стоит тем же адресом, что и список записей, и отличается от него только +методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло +содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя +запись он и создаёт. -Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API -объявлен проектом необратимым, и переименование поля ломает внешнюю программу -молча. Меняются значения поля рубежа, а не его имя. +Ответ MUST нести **список** заведённых записей и место под признак повторного +файла у каждой, даже когда файл в запросе один. Форма согласована один раз и +вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом +нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки — +переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним: +меняется форма ответа, не число файлов. + +Элемент списка MUST нести те же поля, что и карточка записи, плюс признак +повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча. +Состав карточки нормирует capability `archive`. + +Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес +опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка +публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней +программы на прежнем контракте не существует — своего токена у неё не было. + +Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не +перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и +перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет +достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе. + +Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи, +и код с телом такого отказа нормирует capability `archive` наравне с прочими +ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание — +самый частый отказ у человека на мобильной сети — прежде не был нормирован +ничем и уходил телом ограничителя тела, мимо единой формы. Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую не заплатит узнанный отправитель, не должна попасть даже в память. @@ -62,22 +84,23 @@ - **GIVEN** источник метаданных читает запись и отдаёт её длительность - **AND** отправитель предъявил сессию -- **WHEN** программа шлёт `POST /api/audio` с полем `audio` -- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - со значением `uploaded` +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` +- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента +- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и + место под признак повторного файла - **AND** содержимое записи целиком лежит в хранилище одним файлом - **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии #### Scenario: Сессия не даёт учётной записи пользователя - **GIVEN** предъявлена сессия владельца панели -- **WHEN** он шлёт `POST /api/audio` с полем `audio` +- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio` - **THEN** ответ имеет код `403` - **AND** ни файла, ни аудиозаписи не заводится #### Scenario: Сессии нет -- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии - **THEN** ответ имеет код `401` - **AND** ни файла, ни аудиозаписи не заводится - **AND** тело ответа не несёт данных записи @@ -85,7 +108,7 @@ #### Scenario: Поля с записью нет - **GIVEN** отправитель предъявил сессию -- **WHEN** программа шлёт `POST /api/audio` без поля `audio` +- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio` - **THEN** ответ имеет код `400` и сообщение об отсутствии записи - **AND** ни файла, ни аудиозаписи не заводится @@ -100,8 +123,13 @@ Сервис SHALL сохранять принятую запись под собственным именем — идентификатором, к которому приписано расширение из имени файла отправителя. Имя, данное -отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым -своим приёму не подконтрольно. +отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и +содержимым своим приёму не подконтрольно. + +Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной +колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до +журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя +подписывает запись». Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у файла в хранилище расширение было всегда. @@ -130,15 +158,20 @@ ### Requirement: Отказ чтения метаданных Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать -принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в -тело ответа: она принадлежит журналу, а не отправителю. +принятую запись. Ответ MUST иметь код `400`: причина отказа — присланная запись, +а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит +отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит +журналу, а не отправителю. + +Отображение этой ошибки в код и сообщение живёт одним местом на все адреса +приложения; норму держит capability `archive`. #### Scenario: Источник метаданных вернул ошибку - **GIVEN** источник метаданных не может прочитать запись -- **WHEN** программа шлёт `POST /api/audio` с этой записью -- **THEN** ответ имеет код `500` -- **AND** задача расшифровки не заводится +- **WHEN** программа шлёт `POST /app/audiorecords` с этой записью +- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку +- **AND** аудиозаписи не заводится ### Requirement: Имя файла, данное отправителем, не попадает в журнал @@ -148,6 +181,10 @@ личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, откуда строку не убрать. +Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит +один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные +логи. + Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, нормирует требование ниже; наружу расширение выходит только приведённым к @@ -160,16 +197,16 @@ #### Scenario: Имя записи не видно в журнале принятой записи - **GIVEN** источник метаданных читает запись и отдаёт её длительность -- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт - опознаваемую строку при обычном расширении `.mp3` +- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени + несёт опознаваемую строку при обычном расширении `.mp3` - **THEN** ни одна журнальная запись приёма этой строки не содержит - **AND** расширение `.mp3` в журнале допустимо #### Scenario: Имя записи не видно в журнале при отказе приёма - **GIVEN** источник метаданных не может прочитать запись -- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт - опознаваемую строку +- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени + несёт опознаваемую строку - **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой строки не содержит @@ -187,14 +224,14 @@ #### Scenario: Идентификатор, расширение и размер на месте - **GIVEN** источник метаданных читает запись и отдаёт её длительность -- **WHEN** программа шлёт `POST /api/audio` с записью +- **WHEN** программа шлёт `POST /app/audiorecords` с записью - **THEN** журнал приёма несёт идентификатор заведённого файла, расширение принятой записи и её размер в байтах #### Scenario: Имени файла в хранилище в журнале нет - **GIVEN** источник метаданных читает запись и отдаёт её длительность -- **WHEN** программа шлёт `POST /api/audio` с записью +- **WHEN** программа шлёт `POST /app/audiorecords` с записью - **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет ### Requirement: Метка метрики несёт только известное расширение @@ -236,89 +273,6 @@ - **WHEN** программа шлёт запись с именем `sample.MP3` - **THEN** метка метрики принимает значение `mp3` -### Requirement: Опрос готовности задачи - -Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id` -**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело -такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ -владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время -заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и -это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте -отсутствующего текста читается как «расшифровка пуста». - -Видов текста у записи больше одного, поэтому ответ MUST называть вид, который -отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она. -Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы -у одной и той же записи от того, успел ли отработать необязательный шаг, а -контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не -применяться: она делает ответ функцией порядка записи, а не состояния записи. - -Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера: -`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений -`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть. -Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ -перестал быть состоянием. - -Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST -нести признак остановки отдельным полем `halted` со значением истины. Машинный -текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, -а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о -неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки -здесь несёт всю обязанность целиком. - -Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по -кодам ответа перебирается список заведённых записей. - -Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный -идентификатор, — кодом `404` и тем же телом. - -#### Scenario: Запись найдена - -- **GIVEN** отправитель предъявил сессию -- **WHEN** он спрашивает рубеж своей записи -- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at` -- **AND** значение `status` принадлежит перечню рубежей конвейера - -#### Scenario: Запись остановлена - -- **GIVEN** запись остановлена признаком на рубеже приведения -- **WHEN** владелец спрашивает её рубеж -- **THEN** поле `status` несёт рубеж приведения -- **AND** поле `halted` несёт истину -- **AND** машинного текста отказа в ответе нет - -#### Scenario: Сессии нет - -- **WHEN** программа спрашивает рубеж заведённой записи без сессии -- **THEN** ответ имеет код `401` -- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки - -#### Scenario: Без сессии неизвестная запись неотличима от заведённой - -- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем - рубеж по неизвестному идентификатору -- **THEN** оба ответа имеют код `401` - -#### Scenario: Чужая запись неотличима от неизвестной - -- **GIVEN** запись заведена одним вошедшим -- **WHEN** её рубеж спрашивает другой вошедший -- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному - идентификатору -- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки - -#### Scenario: Расшифровки ещё нет - -- **GIVEN** отправитель предъявил сессию -- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста -- **THEN** поля `transcription_text` в ответе нет вовсе - -#### Scenario: Записи с таким идентификатором нет - -- **GIVEN** отправитель предъявил сессию -- **WHEN** программа спрашивает рубеж по неизвестному идентификатору -- **THEN** ответ имеет код `404` и сообщение о ненайденной записи - ### Requirement: Поднятые входы видны наблюдателю Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной @@ -344,3 +298,40 @@ - **THEN** признак поднятости несёт метку входа HTTP со значением единицы - **AND** метки убранного входа Telegram в метриках нет вовсе +### Requirement: Имя файла отправителя подписывает запись + +Приём SHALL класть имя файла, данное отправителем, в собственную колонку +аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек +узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт +название, которое дал человек либо посчитала языковая модель, и одной колонкой на +оба смысла посчитанное название затирало бы то, по чему запись узнают, — а +вернуть затёртое было бы неоткуда. + +Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не +сочиняет. + +Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём +MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем +сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель. + +Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет. + +#### Scenario: Имя доходит до записи + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись с именем `разговор.mp3` +- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3` + +#### Scenario: Заголовок принятой записи пуст + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись с именем `разговор.mp3` +- **THEN** колонка заголовка у заведённой записи пуста + +#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным + +- **GIVEN** отправитель предъявил сессию +- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки +- **THEN** колонка имени файла несёт имя не длиннее предела +- **AND** управляющих знаков в нём нет + diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index 15a0499..6a2e58a 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -568,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд Запись, захваченная с числом отказов сверх заданного предела, MUST останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в -работу. Остановка эта видна отправителю опросом готовности наравне с прочими — -норму держит capability `intake`. +работу. Остановка эта видна владельцу записи **карточкой записи** наравне с +прочими — норму держит capability `archive`. Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни @@ -583,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд - **WHEN** запись проходит заданное число отказов - **THEN** у неё появляется признак остановки - **AND** следующий захват её не выдаёт -- **AND** опрос готовности отдаёт владельцу записи признак остановки +- **AND** карточка записи отдаёт владельцу признак остановки #### Scenario: Шаг уносит процесс, не объявив отказа @@ -608,22 +608,27 @@ MUST не быть привязаны к отдельному шагу: кажд Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход -своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес -опроса и содержимое ответа нормирует capability `intake`. +своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса; +адрес карточки и содержимое ответа нормирует capability `archive`. + +Держатель нормы сменился вместе с убранным опросом готовности: прежде исход +отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше +нет. Обязанность при этом не изменилась — изменилось только то, каким адресом +она исполняется. Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за потерянную ветку. -Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом -готовности — там остановка видна признаком — и журналом владельца, где у неё -стоит причина. Обязанность при этом сменила направление: прежде об отказе +Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой +записи — там остановка видна признаком и причиной — и журналом владельца, где у +неё стоит причина. Обязанность при этом сменила направление: прежде об отказе сообщали, теперь отказ доступен спросившему. Отправитель, который не спрашивает, об остановке не узнаёт. Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, -и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме +и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме хранилища — норму держит capability `storage`. #### Scenario: Готовый текст отправителю не уходит @@ -631,12 +636,12 @@ MUST не быть привязаны к отдельному шагу: кажд - **GIVEN** запись дошла до конечного рубежа - **WHEN** шаг конвейера её завершает - **THEN** ни одного обращения наружу с текстом расшифровки не уходит -- **AND** текст достаётся опросом готовности +- **AND** текст достаётся отдельным адресом текста записи -#### Scenario: Остановка видна опросом, а не сообщением +#### Scenario: Остановка видна карточкой, а не сообщением - **GIVEN** запись остановлена по исчерпании отказов -- **WHEN** владелец записи спрашивает её рубеж -- **THEN** ответ несёт достигнутый рубеж и признак остановки +- **WHEN** владелец записи спрашивает её карточку +- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину - **AND** в журнале владельца сервиса есть запись об остановке с причиной diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index a69369b..60360b1 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -370,6 +370,7 @@ MUST получать владельца своей записи. Иного и - **WHEN** шаг заводит приведённую копию файла - **THEN** владельцем копии стоит владелец записи - **AND** шаг завершается без отказа + ### Requirement: Учётная запись с записями не удаляется Хранилище SHALL отвергать удаление учётной записи, у которой остались @@ -441,12 +442,62 @@ MUST получать владельца своей записи. Иного и вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать отдельными строками: они читаются по открытию одной записи. +Тем же доводом запись MUST нести своими колонками **имя файла, данное +отправителем, длительность и размер**. Все три показываются в списке. Приём +узнаёт длительность и размер у источника метаданных и так, а имя файла приходит +вместе с записью. + +**Имена колонок и единицы измерения нормативны:** `original_filename`, +`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени, +а не в комментарии: шаг схемы применённым не переписывается, а расхождение +«секунды против миллисекунд» между колонкой, ответом списка и объявленным +пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны +потому, что этой единицей уже названы соседние колонки схемы. + +**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не +недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое +кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой +колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе +величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не +удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает +ноль. Решение владельца 2026-08-15. + +Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок +несёт название, которое дал человек либо посчитала языковая модель; имя файла — +то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба +смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. + +Величины на записи и на её файле расходятся по смыслу, и **равенство между ними +не поддерживается никем — намеренно**. На записи лежит снимок **принятого**, +взятый приёмом один раз и больше не пересчитываемый; на файле — величины той +копии, которой файл является сейчас. Приведённая копия имеет свой размер, и +записи он не принадлежит. + +Отсюда норма, без которой два числа читались бы как копии одного: величины +записи MUST не сверяться со строкой файла и MUST не переписываться ничем после +приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек +прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные, +сменили источник, нарезали длинную запись — меняет вторую величину и не трогает +первую. + #### Scenario: Список читается без содержимого - **GIVEN** у записи есть расшифровка - **WHEN** читают запись ради её рубежа и заголовка - **THEN** текст расшифровки при этом не читается +#### Scenario: Длительность и размер читаются без строки файла + +- **GIVEN** запись принята +- **WHEN** читают её длительность и размер +- **THEN** строка файла при этом не читается + +#### Scenario: Посчитанный заголовок не затирает имя файла + +- **GIVEN** запись принята с именем файла отправителя +- **WHEN** записи проставляют заголовок +- **THEN** имя файла остаётся прежним + ### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта @@ -527,8 +578,8 @@ MUST быть помечено защищённым. «какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния. Потребитель текста MUST называть **вид**, который берёт, а не брать последний -записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт -сырую расшифровку — норму держит capability `intake`. +записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит +приложению, называет вид запросом — норму держит capability `archive`. #### Scenario: Расшифровка лежит своей строкой