приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+4 -2
View File
@@ -56,11 +56,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
остаток — [docs/security.md](docs/security.md). остаток — [docs/security.md](docs/security.md).
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
запись пригодной к повтору, либо ставит на неё признак остановки с причиной — запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
и тогда причина видна её владельцу опросом готовности, а владельцу сервиса и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
журналом. Молчаливый выход из шага без записи в лог и без смены состояния журналом. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. **major** отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
переехала на карточку. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
+39
View File
@@ -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`. Оплачено
стадией — на сервере данных нет, внешней программы на прежнем контракте не
существует.
+33
View File
@@ -0,0 +1,33 @@
# Страница архива задаётся ключом, а не номером
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Страница задаётся ключом, а не номером»
## Решение
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
заведения и идентификатор». Номер страницы отвергнут.
## Почему
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
котором записи нет.»
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
между ними одним лишь временем не определён.
## Последствия
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
пропуска;
- `+` порядок между записями с равным временем устойчив;
- `` экран с нумерацией страниц так не сделать — листать можно только
«дальше». Архиву это не нужно;
- `` ключ приходит от клиента и потому разбирается: время приводится к виду
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
истину или ложь.
@@ -0,0 +1,37 @@
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Три новых колонки записи и один шаг схемы»
## Решение
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
уже есть у строки её файла. Равенство между ними не поддерживается никем —
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
## Почему
Обе величины показываются в списке, а список по норме `storage` читается без
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
держать равными. Решением владельца колонки остались, а равенство объявлено
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
файле — величины той копии, которой файл является сейчас». Уточнение
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
«что лежит сейчас».
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
метаданными отвергается отказом и не заводится.
## Последствия
- `+` страница списка не читает по строке файла на каждую запись;
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
- `` в применённом шаге схемы навсегда остаются две колонки, повторяющие
величины строки файла; расхождение между ними — не поломка, и заметить его
нечем;
- `` запись, заведённая рукой в панели без величин, покажет человеку ноль.
+3
View File
@@ -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 | [Вход 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-owner-required-by-schema.md) | |
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | | | 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
+20 -9
View File
@@ -15,7 +15,7 @@
[conventions/go-linters.md](conventions/go-linters.md). [conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём и опрос за сессией, имя отправителя не доходит ни до входов**: приём за сессией, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а хранилища, ни до журнала, метка метрики несёт только известное расширение, а
наблюдатель видит единственный поднятый вход. Задачи наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
@@ -38,6 +38,13 @@
целиком и вложением, как из сохранённого строится структура реплик без целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера. повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14; Задача `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) — кто пришёл в сервис и пускают ли - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login` её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
@@ -82,7 +89,7 @@
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `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 отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности: - **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
остановленная запись отдаёт признак остановки. Владелец — по метрике остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage` `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
@@ -177,11 +184,15 @@
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` | | Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло 2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение
2026-08-13: его читает `internal/clock`, и запрет держит линтер. доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл.
## Деплой ## Деплой
@@ -214,7 +225,7 @@
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
компонентов. компонентов.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом. - **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра текст расшифровки начинает уходить на сторону — сдвиг периметра
+25 -9
View File
@@ -98,20 +98,36 @@ transcriber — **приложение, а не библиотека**: внеш
- **отображение доменной ошибки в статус и сообщение** — единой точкой для - **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба: HTTP и веба:
| Доменная ошибка | Статус | Сообщение | | Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | | --- | --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» | | сессии нет | 401 | `unauthorized` | «требуется вход» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» | | предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» | | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| прочее | 500 | «внутренняя ошибка» | | файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`. логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go` **Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
любую ошибку заведения задачи. `400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
был бы единственным оставшимся путём. Норму держит спека `archive`.
Точка живёт в `internal/controller/http.mapDomainError` и названа в
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
`json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
приходил бы телом библиотеки.
### Разовый ответ и сохранённая диагностика ### Разовый ответ и сохранённая диагностика
+42 -2
View File
@@ -69,6 +69,9 @@ capability, и третий смысл развёл бы одно слово п
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает | | `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется | | `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | | `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
@@ -88,8 +91,39 @@ capability, и третий смысл развёл бы одно слово п
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается | | `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `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: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | | Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | | Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | | Версия вида структуры реплик | 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` | решение владельца: без него часовой разговор даёт два десятка тем | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | | Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | | Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
+11 -9
View File
@@ -92,16 +92,18 @@ Telegram.
2. **Возвращение к записи.** Через месяц человек открывает список, находит 2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку. расшифровку.
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, 3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не токеном, получает идентификатор записи и читает её карточку
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: `GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего
сессией видит только записи того, чью сессию предъявила. входа у программы нет — его заводит `api-tokens`. Записи при этом
разграничены: программа с чужой сессией видит только записи того, чью сессию
предъявила.
4. **Отказ на середине.** Конвертация или распознавание не удались — запись 4. **Отказ на середине.** Конвертация или распознавание не удались — запись
получает признак остановки с причиной, и опрос готовности отдаёт этот признак получает признак остановки с причиной, и карточка записи отдаёт признак и
тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`. доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
## Референсы ## Референсы
+35
View File
@@ -172,6 +172,18 @@
- `conventions`: новая колонка правится в обоих местах репозитория, а новый - `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
2026-08-15 про единую форму отказа).
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим** - `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»; [conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
@@ -280,6 +292,29 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя. оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
`json-api-for-spa`
- **Симптом:** три отказа под корнем приложения — превышение
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
заводилась
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
все три случая как закрытые
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
производителя в рабочем коде
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
вопросом в раздел ниже
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью] ## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put` - **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
+14 -10
View File
@@ -3,7 +3,7 @@
## Периметр ## Периметр
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого — без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра. сегодняшнего — периметра.
@@ -11,7 +11,7 @@
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14** страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса. доступа — страницы расхода для владельца сервиса.
@@ -46,9 +46,11 @@ Telegram — связи чата с учётной записью сервис
новое: **чтение файла базы теперь равносильно чтению секрета клиента**. новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
Отсюда главное следствие, из которого читается всё остальное: **`POST Отсюда главное следствие, из которого читается всё остальное: **`POST
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не /app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги же запросов ограничено только частотой**. Вошедший тратит наши деньги на
на распознавание столько, сколько захочет. распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.
## Недоверенный вход ## Недоверенный вход
@@ -56,8 +58,10 @@ Telegram — связи чата с учётной записью сервис
| Вход | Канал | Кто может слать | | Вход | Канал | Кто может слать |
| --- | --- | --- | | --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | | Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | | Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
@@ -81,8 +85,8 @@ Telegram — связи чата с учётной записью сервис
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
готовности и в панели владельца. и в панели владельца.
Целевой периметр добавляет три пути, каждый — своей задачей: Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -130,7 +134,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку — иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное, - **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`. что защищает карточку записи и её текст.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят - **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
@@ -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
}
@@ -50,6 +50,7 @@ func init() {
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go") pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.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 } func ptr[T any](v T) *T { return &v }
@@ -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
}
@@ -54,6 +54,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
record.Set("source", r.Source) record.Set("source", r.Source)
record.Set("title", derefString(r.Title)) record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief)) 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 { func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
@@ -78,6 +88,13 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")), LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")), StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")), RecognitionID: nilIfEmpty(record.GetString("recognition")),
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(), CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(), UpdatedAt: record.GetDateTime("updated").Time(),
} }
@@ -103,6 +120,25 @@ func dateOrEmpty(v *time.Time) any {
return date 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 { func nilIfEmpty(v string) *string {
if v == "" { if v == "" {
return nil return nil
+52
View File
@@ -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) { func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
declared := map[string]bool{} declared := map[string]bool{}
@@ -363,6 +396,25 @@ func stageDescriptor(t *testing.T) (all []string, working []string) {
} }
// declaredStates — константы рубежей, объявленные доменом. // 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 { func declaredStates(t *testing.T) map[string]bool {
t.Helper() t.Helper()
out := map[string]bool{} out := map[string]bool{}
+37
View File
@@ -10,6 +10,43 @@ import (
// него не кладётся никогда. // него не кладётся никогда.
var ErrOwnerRequired = errors.New("owner is required to accept a record") 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 { type JobNotFoundError struct {
State string State string
Message string Message string
+33
View File
@@ -71,8 +71,41 @@ type AcquiredRecord struct {
Holder string 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 { type AudioRecordRepository interface {
Create(record *entity.AudioRecord) error Create(record *entity.AudioRecord) error
// List отдаёт страницу записей владельца, новыми сверху, не читая ни
// расшифровки, ни структуры реплик.
List(q RecordQuery) (*RecordPage, error)
// ResolveTopicNames разрешает темы названиями одним запросом на страницу и
// сужает их владельцем: словарь тем свой у каждого человека.
ResolveTopicNames(ownerID string, ids []string) (map[string]string, error)
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся // Save сохраняет запись, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит. // за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все // Пустой holder снимает эту условность и в конвейере не употребляется: все
+575
View File
@@ -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, &notFound) {
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 }
+12
View File
@@ -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) return "", fmt.Errorf("failed to build exchange request: %w", err)
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
//
// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
//
// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
// вместе с правилом под корнем приложения, и цена названа здесь.
request.RemoteAddr = "127.0.0.1:0"
handler, err := h.storageHandler() handler, err := h.storageHandler()
if err != nil { if err != nil {
+7 -7
View File
@@ -44,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) {
}) })
t.Run("опрос готовности без сессии", func(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() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
@@ -69,10 +69,10 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
require.Len(t, jobs, 1) require.Len(t, jobs, 1)
existing := httptest.NewRecorder() 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() 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, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code) assert.Equal(t, missing.Code, existing.Code)
@@ -123,7 +123,7 @@ func TestSessionSurvivesRestart(t *testing.T) {
mux, err := r.BuildMux() mux, err := r.BuildMux()
require.NoError(t, err) 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}) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, req) 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}) after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder() afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after) mux.ServeHTTP(afterResponse, after)
@@ -206,7 +206,7 @@ func TestLogoutWhenAccountIsGone(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;") 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}) after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder() afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after) env.mux.ServeHTTP(afterResponse, after)
@@ -345,7 +345,7 @@ func TestCallbackRejectsForeignState(t *testing.T) {
func TestHeaderBeatsCookie(t *testing.T) { func TestHeaderBeatsCookie(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) 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.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session) req.Header.Set("Authorization", env.session)
+427
View File
@@ -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)
}
+235
View File
@@ -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, &notFound) {
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()
},
}
}
+436
View File
@@ -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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
"название чужой темы наружу не выходит")
}
+7 -2
View File
@@ -182,13 +182,18 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
assert.True(t, cleared, "носитель состояния пережил возврат") 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) check.AddCookie(session)
checkResponse := httptest.NewRecorder() checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app) r, err := apis.NewRouter(env.app)
require.NoError(t, err) 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() checkMux, err := r.BuildMux()
require.NoError(t, err) require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check) checkMux.ServeHTTP(checkResponse, check)
+6 -8
View File
@@ -63,10 +63,10 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
_, stranger := newSecondAccount(t, env.app) _, stranger := newSecondAccount(t, env.app)
foreign := httptest.NewRecorder() 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() 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, "чужая задача не отдаётся") require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
assert.Equal(t, unknown.Code, 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("запись"))) env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse response := intakeItemOf(t, w)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
@@ -113,10 +112,9 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env.serve(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse response := intakeItemOf(t, w)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner")) assert.Equal(t, env.account.Id, record.GetString("owner"))
} }
+58
View File
@@ -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
}
+178 -44
View File
@@ -17,22 +17,105 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его // Карточка записи и её текст читаются порознь: шестичасовая расшифровка,
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста. // приехавшая вместе с шапкой, задерживает показ на мобильной сети на время,
// которое человеку не нужно ждать.
// statusOf спрашивает состояние записи от имени её владельца. // cardOf спрашивает карточку записи от имени её владельца.
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder { func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
w := httptest.NewRecorder() 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 return w
} }
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и // textOf спрашивает текст записи названного вида.
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача func textOf(t *testing.T, env *testEnv, recordID, view string) *httptest.ResponseRecorder {
// «последнего записанного» сделала бы ответ функцией порядка записи. t.Helper()
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
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()) env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env) record := jobWithFile(t, env)
@@ -45,61 +128,95 @@ func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
record.TranscriptTextID = &transcript.Id record.TranscriptTextID = &transcript.Id
record.LiteraryTextID = &literary.Id record.LiteraryTextID = &literary.Id
record.MoveToState(entity.StateDone)
require.NoError(t, env.handler.recordRepo.Save(record, "")) 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) require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
assert.Equal(t, entity.StateDone, response.State) assert.Equal(t, entity.TextViewTranscript, text.View)
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя") assert.Equal(t, "сырая расшифровка", text.Contents)
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
assert.NotContains(t, w.Body.String(), "вычитанный текст", assert.NotContains(t, w.Body.String(), "вычитанный текст",
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг") "вычитанный текст этим видом не подменяется: у него своё значение перечня")
} }
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки // Реплики со временем — своё значение перечня, а не форма показа расшифровки:
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись // они лежат структурой разбора и принадлежат записи, а не тексту.
// выглядела бы обычной, стоящей на своём рубеже. func TestRecordText_ReplicasView(t *testing.T) {
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env) 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, "")) 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) require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала") assert.Equal(t, entity.TextViewReplicas, text.View)
assert.True(t, response.Halted, "признак остановки виден отправителю") require.Len(t, text.Replicas, 2)
assert.NotContains(t, w.Body.String(), "сбой конвертации файла", 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()) env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env) record := jobWithFile(t, env)
w := statusOf(t, env, record.Id) texts := pbrepo.NewTextRepository(env.app)
require.Equal(t, http.StatusOK, w.Code) 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) var body ErrorBody
assert.False(t, response.Halted, "запись в работе остановленной не значится") require.NoError(t, json.Unmarshal(notReady.Body.Bytes(), &body))
assert.Nil(t, response.TranscriptionText) 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 отказывает на чтении текста — так выглядит недоступное // 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()) env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env) record := jobWithFile(t, env)
@@ -134,9 +251,10 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная // Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде. // цепочка та же, что и в проде.
journal := &journalBuffer{} journal := &journalBuffer{}
handler := NewTranscribeHandler( handler := NewAppHandler(
env.handler.recordRepo, env.handler.recordRepo,
&failingTextRepo{}, &failingTextRepo{},
pbrepo.NewStructureRepository(env.app),
env.handler.trsService, env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)), slog.New(slog.NewTextHandler(journal, nil)),
) )
@@ -147,7 +265,7 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
mux, err := r.BuildMux() mux, err := r.BuildMux()
require.NoError(t, err) 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}) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, req) mux.ServeHTTP(w, req)
@@ -155,6 +273,22 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code, assert.Equal(t, http.StatusInternalServerError, w.Code,
"отказ хранилища не выдаётся за отсутствие записи") "отказ хранилища не выдаётся за отсутствие записи")
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят") 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)
}
-169
View File
@@ -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, &notFound) {
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)
}
+76 -44
View File
@@ -69,7 +69,7 @@ func readableMetaViewer() *stubMetaViewer {
// рабочий каталог процесса проверки не трогают. // рабочий каталог процесса проверки не трогают.
type testEnv struct { type testEnv struct {
mux http.Handler mux http.Handler
handler *TranscribeHandler handler *AppHandler
app core.App app core.App
journal *journalBuffer journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за // session — значение сессии вошедшего. Приём и опрос закрыты за
@@ -189,7 +189,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger, 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 кладёт запись в поле с заданным именем — // createMultipartRequestWithField кладёт запись в поле с заданным именем —
// нужно, чтобы построить форму без поля `audio`. // нужно, чтобы построить форму без поля `audio`.
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request { 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 var buf bytes.Buffer
writer := multipart.NewWriter(&buf) writer := multipart.NewWriter(&buf)
@@ -233,12 +243,23 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
err = writer.Close() err = writer.Close()
require.NoError(t, err) require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/audio", &buf) req := httptest.NewRequest("POST", path, &buf)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
return req 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 отдаёт имена, под которыми файлы легли в хранилище. // storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string { func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(migrations.FilesCollection) records, err := env.app.FindAllRecords(migrations.FilesCollection)
@@ -316,27 +337,32 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
// Имена полей ответа нормативны: контракт HTTP API объявлен необратимым. // Имена полей ответа нормативны: контракт объявлен необратимым, а экраны
// Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал // строятся на этих именах. Судим по сырому JSON — разбор в структуру
// бы тег вместе с ожиданием, и проверка не смогла бы упасть. // переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть.
var raw map[string]json.RawMessage //
err := json.Unmarshal(w.Body.Bytes(), &raw) // Ответ — **список**, даже когда файл в запросе один: форма согласована
// вперёд, чтобы приём нескольких файлов и распознавание повтора её не
// переписывали.
var rawItems []map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &rawItems)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, raw, "job_id") require.Len(t, rawItems, 1)
assert.Contains(t, raw, "status") 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 response := intakeItemOf(t, w)
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State) assert.Equal(t, entity.StateUploaded, response.State)
// Задача действительно заведена, а не только названа в ответе: иначе // Задача действительно заведена, а не только названа в ответе: иначе
// отправитель получит идентификатор записи, которой не будет никогда. // отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env)) 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) require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, job.State) assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID) require.NotNil(t, job.OriginalFileID)
@@ -359,7 +385,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
req: func(t *testing.T) *http.Request { 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) require.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string var response ErrorBody
err := json.Unmarshal(w.Body.Bytes(), &response) err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) 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, countFiles(t, env))
assert.Equal(t, 0, countJobs(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) require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse response := intakeItemOf(t, w)
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State) assert.Equal(t, entity.StateUploaded, response.State)
} }
@@ -492,15 +517,19 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.serve(w, req) 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) err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) 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(), "не удалось прочитать запись")
assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом")
assert.Equal(t, 0, countJobs(t, env)) assert.Equal(t, 0, countJobs(t, env))
} }
@@ -555,7 +584,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.serve(w, req) env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
journal := env.journal.String() journal := env.journal.String()
@@ -603,10 +632,9 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse response := intakeItemOf(t, w)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
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.NoError(t, err)
require.NotNil(t, job.OriginalFileID) require.NotNil(t, job.OriginalFileID)
@@ -690,19 +718,19 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
job := jobWithFile(t, env) 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() w := httptest.NewRecorder()
env.serve(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse var response RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) 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.Equal(t, entity.StateUploaded, response.State)
assert.NotZero(t, response.CreatedAt) assert.NotEmpty(t, response.CreatedAt)
} }
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) { func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
@@ -710,44 +738,48 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
job := jobWithFile(t, env) 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() w := httptest.NewRecorder()
env.serve(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
// Судим по сырому JSON: пустая строка на месте отсутствующего текста // Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру
// читается клиентом как «расшифровка пуста», и разобранная структура // переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть.
// эти два случая не различает.
var raw map[string]json.RawMessage var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Contains(t, raw, "job_id") assert.Contains(t, raw, "id")
assert.Contains(t, raw, "status") assert.Contains(t, raw, "state")
assert.Contains(t, raw, "created_at") 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) { func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) 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() w := httptest.NewRecorder()
env.serve(w, req) env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code) require.Equal(t, http.StatusNotFound, w.Code)
var response map[string]string var response ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) 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. // решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) { func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
+63
View File
@@ -1,7 +1,9 @@
package entity package entity
import ( import (
"strings"
"time" "time"
"unicode"
"git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/clock"
) )
@@ -62,6 +64,33 @@ type AudioRecord struct {
Title *string Title *string
Brief *string Brief *string
// OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от
// заголовка**: заголовок несёт название, которое дал человек либо посчитала
// языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока
// заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы
// имя, и вернуть затёртое было бы неоткуда.
//
// Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает
// управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант
// приватности.
OriginalFilename *string
// DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма.
// Со строкой файла они намеренно не сверяются: там лежат величины той копии,
// которой файл является сейчас, и уточнение длительности меняет их, не трогая
// эти. Нужны колонками записи, потому что показываются в списке.
//
// Указатели здесь не выражают «неизвестно»: числовая колонка хранилища
// пустого значения не держит, и пустое кладётся нулём. Обе величины ставит
// приём и ставит всегда — запись с непрочитанными метаданными отвергается
// отказом и не заводится вовсе. Решение владельца 2026-08-15.
DurationMs *int64
SizeBytes *int64
// TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено
// вперёд, заполняет его задача, считающая темы языковой моделью.
TopicIDs []string
State string State string
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу. // StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
// Откладывание опроса его не двигает — иначе застревание в чужой операции // Откладывание опроса его не двигает — иначе застревание в чужой операции
@@ -99,6 +128,40 @@ type AudioRecord struct {
UpdatedAt time.Time 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 — закрытый перечень рубежей для схемы хранилища. // AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string { func AllStates() []string {
out := make([]string, 0, len(stages)) out := make([]string, 0, len(stages))
+69
View File
@@ -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,
"сто управляющих знаков не откусили сто знаков имени")
}
+53
View File
@@ -76,6 +76,59 @@ func StageByName(name string) (Stage, bool) {
return Stage{}, false 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 — пределы простоя, приходящие из настроек. // StuckLimits — пределы простоя, приходящие из настроек.
type StuckLimits struct { type StuckLimits struct {
// Own — предел на своей работе. // Own — предел на своей работе.
+30
View File
@@ -12,6 +12,36 @@ const (
TextKindLiterary = "literary" 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 — закрытый перечень видов текста для схемы хранилища. // AllTextKinds — закрытый перечень видов текста для схемы хранилища.
func AllTextKinds() []string { func AllTextKinds() []string {
return []string{TextKindTranscript, TextKindLiterary} return []string{TextKindTranscript, TextKindLiterary}
+28
View File
@@ -1,6 +1,7 @@
package metrics package metrics
import ( import (
"slices"
"strconv" "strconv"
"strings" "strings"
) )
@@ -33,6 +34,33 @@ var knownFormats = map[string]struct{}{
"audio": {}, // умолчание сервиса, когда расширения в имени не было "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 приводит расширение к виду, годному для метки метрики. // FormatLabel приводит расширение к виду, годному для метки метрики.
// //
// Расширение приходит из имени, которое дал отправитель, и потому может быть // Расширение приходит из имени, которое дал отправитель, и потому может быть
+8
View File
@@ -33,6 +33,14 @@ func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) {
return nil, errors.New("не зовётся этими проверками") 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) { func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) {
return nil, r.err return nil, r.err
} }
+40 -4
View File
@@ -22,6 +22,16 @@ import (
const ( const (
defaultAudioExt = "audio" 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) { 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) ext := filepath.Ext(fileName)
if ext == "" { if ext == "" || len(ext) > maxExtLen {
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено 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()) info, err := s.metaviewer.GetInfo(ctx, work.Path())
if err != nil { if err != nil {
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext) 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() size, err := work.Size()
@@ -217,6 +236,23 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
r.OriginalFileID = &fileRecord.Id r.OriginalFileID = &fileRecord.Id
r.StateEnteredAt = clock.Now() 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 { if err := s.repos.Records.Create(r); err != nil {
s.logger.Error("Failed to create audio record", "error", err, "file_id", fileRecord.Id) s.logger.Error("Failed to create audio record", "error", err, "file_id", fileRecord.Id)
return nil, err return nil, err
+4 -4
View File
@@ -27,13 +27,13 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) {
}, },
{ {
name: "прочие пути не трогаются", name: "прочие пути не трогаются",
path: "/api/status/abc123def456ghi", path: "/app/audiorecords/abc123def456ghi",
want: "/api/status/abc123def456ghi", want: "/app/audiorecords/abc123def456ghi",
}, },
{ {
name: "приём не трогается", name: "приём не трогается",
path: "/api/audio", path: "/app/audiorecords",
want: "/api/audio", want: "/app/audiorecords",
}, },
{ {
name: "сам префикс без имени не портится", name: "сам префикс без имени не портится",
+10 -2
View File
@@ -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{ authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL, AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL, RedirectURL: cfg.Auth.RedirectURL,
@@ -214,8 +214,16 @@ func main() {
return fmt.Errorf("failed to apply provider settings: %w", err) 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) authHandler.Register(se.Router)
transcribeHandler.Register(se.Router) appHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error { se.Router.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{ return e.JSON(http.StatusOK, map[string]string{
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-15
@@ -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, состав
адресов согласован там же.
@@ -0,0 +1,70 @@
## Why
Приложение строить не на чем. Сегодня сервис отвечает «записи нет» на упавшую
базу и «внутренняя ошибка» на негодный файл: код ответа называет место, где
отказ случился, а не его причину. Экран, собранный на таком контракте, показывает
человеку «не найдено», когда на самом деле лежит хранилище.
Списка своих записей у сервиса нет вовсе, карточка и текст едут одним ответом, а
пределы, которыми сервис ограничивает загрузку, приложению неоткуда узнать —
кроме как повторить их своей константой и разойтись с сервером молча.
## What Changes
- **BREAKING.** Опрос готовности убирается целиком вместе со своими именами
полей. Стадия проекта — стройка, на сервере данных нет, а внешней программы на
прежнем контракте не существует: своего токена у неё не было.
- **BREAKING.** Приложение уезжает из общего пространства `/api/` в своё `/app/`.
Общее пространство принадлежит хранилищу, и обновление библиотеки вправе занять
там имя рядом с нашим.
- **BREAKING.** Приём стоит тем же адресом, что и список, и отличается методом:
он заводит аудиозапись, а не кладёт файл. Ответ приёма отдаёт список заведённых
записей и место под признак повторного файла — форма согласуется один раз,
чтобы соседние задачи её не переписывали.
- Отказ отвечает своей причиной: сбой хранилища виден как сбой, негодная запись —
как негодная, отказ по чужому имени — как отказ. Тело отказа одной формы на
всех адресах приложения, и собирает его одно место.
- Появляется чтение своего архива: страница записей новыми сверху, карточка
записи без текста и текст названного вида — сплошной либо репликами со
временем. Шестичасовая расшифровка больше не задерживает показ шапки.
- Появляется адрес, которым сервис объявляет свои пределы: потолок размера
записи, частота опроса, перечень известных расширений, потолок тем.
- Запись подписывается именем файла, данным отправителем: имя ложится своей
колонкой и не спорит с заголовком, который дал человек либо посчитала языковая
модель. Длина ограничена, управляющие знаки убраны.
- Длительность и размер переезжают колонками записи: список читается без
содержимого, а обе величины приём узнаёт у источника метаданных и так.
## Capabilities
### New Capabilities
- `archive`: архив своих записей глазами приложения — пространство адресов
приложения и единая форма отказа, пределы, которыми сервис ограничивает
загрузку, и чтение своего архива: страница записей, карточка и текст названного
вида.
### Modified Capabilities
- `intake`: приём переезжает на новый адрес и меняет форму ответа на список;
опрос готовности убирается целиком; имя, данное отправителем, доходит до самой
записи отдельной колонкой, оставаясь за пределами имени файла в хранилище и
журнала.
- `access`: область слоя предъявления сессии названа адресами приложения, и
среди них появляется вопрос «кто вошёл».
- `storage`: у записи появляются колонки имени файла отправителя, длительности и
размера.
- `pipeline`: исход своей записи владелец узнаёт карточкой записи, а не убранным
опросом готовности; два требования называли держателем нормы адрес, которого
больше нет.
## Impact
- Приём и чтение записей по HTTP: все адреса приложения, коды ответа и форма
тела.
- Схема хранилища: новый шаг под три колонки записи.
- Слой предъявления сессии и своё правило ограничителя частоты переезжают на
новый корень.
- Правило неизвестного пути в приложении перечисляет корни сервиса, а не один.
- Внешней программе на прежнем контракте ломается всё; такой программы у сервиса
сегодня нет.
@@ -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` к вредоносному входу; поведение браузера с куками.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет —
проход независимой реализации упразднён решением о стоимости. «Не знаю, чего
не знаю» здесь не достаёт никто, и на изменении, замораживающем публичную
форму ответов, это дорого.
@@ -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/` прогоном не открывались: процессные документы.
Расхождение изменения с записанным решением этой стадией не ловится — это
работа сверки документации.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет:
проход независимой реализации упразднён решением о стоимости.
- Что будет с этим на живых данных, не проверял никто: данных нет, стадия —
стройка.
@@ -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** адреса почты в ответе нет
@@ -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`
@@ -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`.
Переносить нечего: стадия проекта — стройка, данных на сервере нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
@@ -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** в журнале владельца сервиса есть запись об остановке с причиной
@@ -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** строка расшифровки у записи одна
@@ -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` с одним телом.
- Карточка остановленной записи несёт рубеж, признак остановки и её причину, а
машинного текста отказа не несёт. Оракул — тест на остановленной записи.
+56 -11
View File
@@ -149,15 +149,19 @@ MUST не заводить: учётные записи держит прова
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища. кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
и опросом готовности. Собственная поверхность хранилища под него не подпадает: живут под его собственным корнем. Собственная поверхность хранилища под него не
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
расширение слоя на всё сняло бы эту защиту молча. шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ #### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии - **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка - **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит - **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом #### Scenario: Кука защищена от чтения скриптом
@@ -170,6 +174,12 @@ MUST не заводить: учётные записи держит прова
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization` - **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки - **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
### Requirement: Значение, дающее доступ, не печатается ### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии, Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
@@ -362,10 +372,11 @@ MUST не делать. Кто допущен, определяет правил
имя. имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей. Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то, ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один. capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище **спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
@@ -377,13 +388,13 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Своя запись доступна #### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись - **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией - **WHEN** он спрашивает карточку этой записи своей сессией
- **THEN** ответ несёт состояние записи - **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей #### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим - **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший - **WHEN** её карточку спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом #### Scenario: Владельца не задают запросом
@@ -399,5 +410,39 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Пустой владелец не открывает ничего #### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая - **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем - **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор - **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** адреса почты в ответе нет
+470
View File
@@ -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`
+104 -113
View File
@@ -12,21 +12,43 @@
## Requirements ## Requirements
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**. `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести получить заведённую под неё аудиозапись на рубеже `uploaded`.
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `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** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию - **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
со значением `uploaded` - **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом - **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии - **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя #### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели - **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio` - **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403` - **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится - **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет #### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии - **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии
- **THEN** ответ имеет код `401` - **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится - **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи - **AND** тело ответа не несёт данных записи
@@ -85,7 +108,7 @@
#### Scenario: Поля с записью нет #### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию - **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio` - **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи - **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится - **AND** ни файла, ни аудиозаписи не заводится
@@ -100,8 +123,13 @@
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором, Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и
своим приёму не подконтрольно. содержимым своим приёму не подконтрольно.
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя
подписывает запись».
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда. файла в хранилище расширение было всегда.
@@ -130,15 +158,20 @@
### Requirement: Отказ чтения метаданных ### Requirement: Отказ чтения метаданных
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в принятую запись. Ответ MUST иметь код `400`: причина отказа — присланная запись,
тело ответа: она принадлежит журналу, а не отправителю. а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит
отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит
журналу, а не отправителю.
Отображение этой ошибки в код и сообщение живёт одним местом на все адреса
приложения; норму держит capability `archive`.
#### Scenario: Источник метаданных вернул ошибку #### Scenario: Источник метаданных вернул ошибку
- **GIVEN** источник метаданных не может прочитать запись - **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с этой записью - **WHEN** программа шлёт `POST /app/audiorecords` с этой записью
- **THEN** ответ имеет код `500` - **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** задача расшифровки не заводится - **AND** аудиозаписи не заводится
### Requirement: Имя файла, данное отправителем, не попадает в журнал ### Requirement: Имя файла, данное отправителем, не попадает в журнал
@@ -148,6 +181,10 @@
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать. откуда строку не убрать.
Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит
один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные
логи.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к нормирует требование ниже; наружу расширение выходит только приведённым к
@@ -160,16 +197,16 @@
#### Scenario: Имя записи не видно в журнале принятой записи #### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт - **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
опознаваемую строку при обычном расширении `.mp3` несёт опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит - **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо - **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма #### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись - **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт - **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
опознаваемую строку несёт опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой - **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит строки не содержит
@@ -187,14 +224,14 @@
#### Scenario: Идентификатор, расширение и размер на месте #### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью - **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение - **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет #### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью - **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет - **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение ### Requirement: Метка метрики несёт только известное расширение
@@ -236,89 +273,6 @@
- **WHEN** программа шлёт запись с именем `sample.MP3` - **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `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: Поднятые входы видны наблюдателю ### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
@@ -344,3 +298,40 @@
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы - **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе - **AND** метки убранного входа Telegram в метриках нет вовсе
### Requirement: Имя файла отправителя подписывает запись
Приём SHALL класть имя файла, данное отправителем, в собственную колонку
аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек
узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт
название, которое дал человек либо посчитала языковая модель, и одной колонкой на
оба смысла посчитанное название затирало бы то, по чему запись узнают, — а
вернуть затёртое было бы неоткуда.
Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не
сочиняет.
Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём
MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем
сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель.
Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет.
#### Scenario: Имя доходит до записи
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3`
#### Scenario: Заголовок принятой записи пуст
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка заголовка у заведённой записи пуста
#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет
+18 -13
View File
@@ -568,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд
Запись, захваченная с числом отказов сверх заданного предела, MUST Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Остановка эта видна отправителю опросом готовности наравне с прочими — работу. Остановка эта видна владельцу записи **карточкой записи** наравне с
норму держит capability `intake`. прочими — норму держит capability `archive`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
@@ -583,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** запись проходит заданное число отказов - **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки - **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт - **AND** следующий захват её не выдаёт
- **AND** опрос готовности отдаёт владельцу записи признак остановки - **AND** карточка записи отдаёт владельцу признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа #### Scenario: Шаг уносит процесс, не объявив отказа
@@ -608,22 +608,27 @@ MUST не быть привязаны к отдельному шагу: кажд
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса;
опроса и содержимое ответа нормирует capability `intake`. адрес карточки и содержимое ответа нормирует capability `archive`.
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
она исполняется.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку. потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
готовности — там остановка видна признаком — и журналом владельца, где у неё записи — там остановка видна признаком и причиной — и журналом владельца, где у
стоит причина. Обязанность при этом сменила направление: прежде об отказе неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт. спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`. хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит #### Scenario: Готовый текст отправителю не уходит
@@ -631,12 +636,12 @@ MUST не быть привязаны к отдельному шагу: кажд
- **GIVEN** запись дошла до конечного рубежа - **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает - **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит - **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности - **AND** текст достаётся отдельным адресом текста записи
#### Scenario: Остановка видна опросом, а не сообщением #### Scenario: Остановка видна карточкой, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов - **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж - **WHEN** владелец записи спрашивает её карточку
- **THEN** ответ несёт достигнутый рубеж и признак остановки - **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
- **AND** в журнале владельца сервиса есть запись об остановке с причиной - **AND** в журнале владельца сервиса есть запись об остановке с причиной
+53 -2
View File
@@ -370,6 +370,7 @@ MUST получать владельца своей записи. Иного и
- **WHEN** шаг заводит приведённую копию файла - **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи - **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа - **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется ### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались Хранилище SHALL отвергать удаление учётной записи, у которой остались
@@ -441,12 +442,62 @@ MUST получать владельца своей записи. Иного и
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи. отдельными строками: они читаются по открытию одной записи.
Тем же доводом запись MUST нести своими колонками **имя файла, данное
отправителем, длительность и размер**. Все три показываются в списке. Приём
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
вместе с записью.
**Имена колонок и единицы измерения нормативны:** `original_filename`,
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
ноль. Решение владельца 2026-08-15.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
записи он не принадлежит.
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
первую.
#### Scenario: Список читается без содержимого #### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка - **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка - **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается - **THEN** текст расшифровки при этом не читается
#### Scenario: Длительность и размер читаются без строки файла
- **GIVEN** запись принята
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит ### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
@@ -527,8 +578,8 @@ MUST быть помечено защищённым.
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния. «какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит
сырую расшифровку — норму держит capability `intake`. приложению, называет вид запросом — норму держит capability `archive`.
#### Scenario: Расшифровка лежит своей строкой #### Scenario: Расшифровка лежит своей строкой