приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -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**
|
||||||
|
|||||||
@@ -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`. Оплачено
|
||||||
|
стадией — на сервере данных нет, внешней программы на прежнем контракте не
|
||||||
|
существует.
|
||||||
@@ -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` читается без
|
||||||
|
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
|
||||||
|
держать равными. Решением владельца колонки остались, а равенство объявлено
|
||||||
|
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
|
||||||
|
файле — величины той копии, которой файл является сейчас». Уточнение
|
||||||
|
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
|
||||||
|
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
|
||||||
|
«что лежит сейчас».
|
||||||
|
|
||||||
|
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
|
||||||
|
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
|
||||||
|
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
|
||||||
|
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||||
|
метаданными отвергается отказом и не заводится.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` страница списка не читает по строке файла на каждую запись;
|
||||||
|
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
|
||||||
|
- `−` в применённом шаге схемы навсегда остаются две колонки, повторяющие
|
||||||
|
величины строки файла; расхождение между ними — не поломка, и заметить его
|
||||||
|
нечем;
|
||||||
|
- `−` запись, заведённая рукой в панели без величин, покажет человеку ноль.
|
||||||
@@ -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
@@ -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 отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|
||||||
## Референсы
|
## Референсы
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,8 +88,15 @@ 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")),
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
|
||||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
|
||||||
|
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
|
||||||
|
// инвариант о колонках компилятор не проверяет.
|
||||||
|
DurationMs: numberValue(record.GetInt("duration_ms")),
|
||||||
|
SizeBytes: numberValue(record.GetInt("size_bytes")),
|
||||||
|
TopicIDs: record.GetStringSlice("topics"),
|
||||||
|
CreatedAt: record.GetDateTime("created").Time(),
|
||||||
|
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +120,25 @@ func dateOrEmpty(v *time.Time) any {
|
|||||||
return date
|
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
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 снимает эту условность и в конвейере не употребляется: все
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppRoot — корень адресов приложения.
|
||||||
|
//
|
||||||
|
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
|
||||||
|
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
|
||||||
|
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
|
||||||
|
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
|
||||||
|
// нашим, и разойдутся они молча.
|
||||||
|
const AppRoot = "/app"
|
||||||
|
|
||||||
|
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||||||
|
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
|
||||||
|
// одним запросом и тем обойти постраничность её же параметром.
|
||||||
|
const (
|
||||||
|
DefaultPageLimit = 30
|
||||||
|
MaxPageLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
|
||||||
|
//
|
||||||
|
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
|
||||||
|
// список, открывает карточку соседней записи и грузит новую, а бюджет
|
||||||
|
// ограничителя один на все адреса приложения и считается по адресу
|
||||||
|
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
|
||||||
|
// его пополам.
|
||||||
|
const pollBudgetShare = 8
|
||||||
|
|
||||||
|
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
|
||||||
|
//
|
||||||
|
// Выводится из настройки ограничителя частоты под корнем приложения, а не
|
||||||
|
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||||||
|
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||||||
|
// которого сервис сам же ему обещал избежать.
|
||||||
|
//
|
||||||
|
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
|
||||||
|
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
|
||||||
|
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
|
||||||
|
// выполняется с запасом.
|
||||||
|
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
||||||
|
|
||||||
|
type AppHandler struct {
|
||||||
|
recordRepo contract.AudioRecordRepository
|
||||||
|
textRepo contract.TextRepository
|
||||||
|
structureRepo contract.StructureRepository
|
||||||
|
trsService *service.TranscribeService
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppHandler(
|
||||||
|
recordRepo contract.AudioRecordRepository,
|
||||||
|
textRepo contract.TextRepository,
|
||||||
|
structureRepo contract.StructureRepository,
|
||||||
|
trsService *service.TranscribeService,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *AppHandler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &AppHandler{
|
||||||
|
recordRepo: recordRepo,
|
||||||
|
textRepo: textRepo,
|
||||||
|
structureRepo: structureRepo,
|
||||||
|
trsService: trsService,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
|
||||||
|
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
|
||||||
|
// другую.
|
||||||
|
//
|
||||||
|
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||||||
|
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||||||
|
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
|
||||||
|
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
|
||||||
|
type RecordView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
OriginalFilename *string `json:"original_filename"`
|
||||||
|
Brief *string `json:"brief"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Halted bool `json:"halted"`
|
||||||
|
HaltReason *string `json:"halt_reason"`
|
||||||
|
DurationMs *int64 `json:"duration_ms"`
|
||||||
|
SizeBytes *int64 `json:"size_bytes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
// AvailableViews — перечень доступных видов текста, а не признак «текст
|
||||||
|
// есть». Видов больше одного, и шаг завершения пишет их несколькими
|
||||||
|
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
|
||||||
|
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
|
||||||
|
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
|
||||||
|
//
|
||||||
|
// У элемента страницы поле опущено: страница видов не читает.
|
||||||
|
AvailableViews *[]string `json:"available_views,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||||||
|
//
|
||||||
|
// Место под признак заведено вперёд и заполняется другой задачей. Форма
|
||||||
|
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
|
||||||
|
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
|
||||||
|
// форму.
|
||||||
|
type IntakeItem struct {
|
||||||
|
RecordView
|
||||||
|
Duplicate bool `json:"duplicate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageView struct {
|
||||||
|
Items []RecordView `json:"items"`
|
||||||
|
NextCursor *string `json:"next_cursor"`
|
||||||
|
TotalItems int `json:"total_items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigView struct {
|
||||||
|
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
|
||||||
|
MaxPageSize int `json:"max_page_size"`
|
||||||
|
PollIntervalMs int64 `json:"poll_interval_ms"`
|
||||||
|
KnownExtensions []string `json:"known_extensions"`
|
||||||
|
MaxTopicsPerRecord int `json:"max_topics_per_record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextView struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Contents string `json:"contents,omitempty"`
|
||||||
|
Replicas []ReplicaView `json:"replicas,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplicaView struct {
|
||||||
|
StartMs int64 `json:"start_ms"`
|
||||||
|
EndMs int64 `json:"end_ms"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
|
||||||
|
// панели один, поэтому и роутер один.
|
||||||
|
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||||
|
app := r.Group(AppRoot)
|
||||||
|
|
||||||
|
// Слой сессии вешается на **группу корня**, а не на перечень адресов:
|
||||||
|
// перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес
|
||||||
|
// молча перестал бы принимать куку. Собственная поверхность хранилища под
|
||||||
|
// слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам
|
||||||
|
// не шлёт.
|
||||||
|
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
||||||
|
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
||||||
|
// иначе ушли бы телом библиотеки, мимо единой формы.
|
||||||
|
app.Bind(OneErrorForm())
|
||||||
|
app.Bind(SessionFromCookie())
|
||||||
|
app.Bind(RequireUser(migrations.UsersCollection))
|
||||||
|
|
||||||
|
app.GET("/me", h.Me)
|
||||||
|
app.GET("/config", h.Config)
|
||||||
|
|
||||||
|
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||||
|
// заводит аудиозапись, а не кладёт файл.
|
||||||
|
//
|
||||||
|
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||||
|
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
|
||||||
|
// самой записи, а отказ по нему уходит нашей формой.
|
||||||
|
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
||||||
|
app.GET("/audiorecords", h.ListRecords)
|
||||||
|
app.GET("/audiorecords/{id}", h.GetRecord)
|
||||||
|
app.GET("/audiorecords/{id}/text", h.GetRecordText)
|
||||||
|
|
||||||
|
// Перехват «под нашим корнем такого адреса нет».
|
||||||
|
//
|
||||||
|
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
|
||||||
|
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
|
||||||
|
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
|
||||||
|
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
|
||||||
|
// под корнем приложения было бы две.
|
||||||
|
//
|
||||||
|
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
|
||||||
|
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
|
||||||
|
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
|
||||||
|
app.Any("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
||||||
|
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||||
|
// человеку, а не сервису.
|
||||||
|
return e.JSON(http.StatusOK, MeView{
|
||||||
|
ID: e.Auth.Id,
|
||||||
|
Name: e.Auth.GetString("name"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) Config(e *core.RequestEvent) error {
|
||||||
|
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||||||
|
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||||||
|
// до первого отказа на записи, которую человек уже успел отправить.
|
||||||
|
return e.JSON(http.StatusOK, ConfigView{
|
||||||
|
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||||||
|
MaxPageSize: MaxPageLimit,
|
||||||
|
PollIntervalMs: PollIntervalMs,
|
||||||
|
KnownExtensions: metrics.PublicFormats(),
|
||||||
|
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
||||||
|
file, header, err := e.Request.FormFile("audio")
|
||||||
|
if err != nil {
|
||||||
|
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
|
||||||
|
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
|
||||||
|
// человеку «вы не приложили файл» о записи, которую он приложил и
|
||||||
|
// которая просто больше потолка.
|
||||||
|
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
|
||||||
|
return fail(e, contract.ErrRecordTooLarge)
|
||||||
|
}
|
||||||
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
h.logger.Error("Failed to close uploaded file", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||||
|
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||||
|
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||||
|
// может и позже — карточкой записи. Значения контекста (журнал запроса,
|
||||||
|
// сессия) при этом сохраняются, теряется только отмена.
|
||||||
|
ctx := context.WithoutCancel(e.Request.Context())
|
||||||
|
|
||||||
|
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||||
|
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||||
|
// чужое имя.
|
||||||
|
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||||
|
if err != nil {
|
||||||
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||||
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||||||
|
// делает это одним местом — по причине отказа, а не по месту.
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||||||
|
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||||||
|
return e.JSON(http.StatusCreated, []IntakeItem{{
|
||||||
|
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||||||
|
// отсутствие поля и пустой перечень приложение не различит.
|
||||||
|
RecordView: h.viewOf(record, nil, &[]string{}),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
||||||
|
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
|
||||||
|
|
||||||
|
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
||||||
|
limit, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||||
|
}
|
||||||
|
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||||||
|
// сервис отдаёт, но просьба сама по себе не негодна.
|
||||||
|
q.Limit = min(limit, MaxPageLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
|
||||||
|
filter, ok := entity.ParseListFilter(raw)
|
||||||
|
if !ok {
|
||||||
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||||
|
}
|
||||||
|
q.Filter = &filter
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
|
||||||
|
cursor, err := decodeCursor(raw)
|
||||||
|
if err != nil {
|
||||||
|
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||||||
|
// архив, листающийся по кругу, и ни строки в журнале.
|
||||||
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||||
|
}
|
||||||
|
q.Cursor = cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := h.recordRepo.List(q)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := h.topicNames(e.Auth.Id, page.Items)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
||||||
|
for _, record := range page.Items {
|
||||||
|
// Страница видов текста не читает: перечень доступных видов есть только у
|
||||||
|
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
|
||||||
|
// «текста нет».
|
||||||
|
view.Items = append(view.Items, h.viewOf(record, names, nil))
|
||||||
|
}
|
||||||
|
if page.NextCursor != nil {
|
||||||
|
encoded := encodeCursor(page.NextCursor)
|
||||||
|
view.NextCursor = &encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
|
||||||
|
record, err := h.readOwn(e)
|
||||||
|
if err != nil {
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
views := h.availableViews(record)
|
||||||
|
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
|
||||||
|
view := e.Request.URL.Query().Get("view")
|
||||||
|
if !entity.IsKnownTextView(view) {
|
||||||
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := h.readOwn(e)
|
||||||
|
if err != nil {
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if view == entity.TextViewReplicas {
|
||||||
|
return h.replicasOf(e, record)
|
||||||
|
}
|
||||||
|
return h.plainTextOf(e, record, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
|
||||||
|
// одним и тем же: по разнице ответов иначе перебирается список заведённых
|
||||||
|
// записей.
|
||||||
|
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
|
||||||
|
recordID := e.Request.PathValue("id")
|
||||||
|
|
||||||
|
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
||||||
|
if err != nil {
|
||||||
|
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||||
|
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||||
|
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||||
|
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||||
|
// аварии не узнаёт ниоткуда.
|
||||||
|
var notFound *contract.JobNotFoundError
|
||||||
|
if !errors.As(err, ¬Found) {
|
||||||
|
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
|
||||||
|
textID := record.TranscriptTextID
|
||||||
|
if view == entity.TextViewLiterary {
|
||||||
|
textID = record.LiteraryTextID
|
||||||
|
}
|
||||||
|
|
||||||
|
if textID == nil {
|
||||||
|
return fail(e, contract.ErrTextNotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := h.textRepo.GetByID(*textID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
if text.Contents == "" {
|
||||||
|
return fail(e, contract.ErrTextNotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
|
||||||
|
if record.StructureID == nil {
|
||||||
|
return fail(e, contract.ErrTextNotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
||||||
|
return fail(e, err)
|
||||||
|
}
|
||||||
|
if len(structure.Replicas) == 0 {
|
||||||
|
return fail(e, contract.ErrTextNotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||||||
|
for _, r := range structure.Replicas {
|
||||||
|
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
||||||
|
// сотню записей иначе стоила бы сотни обращений к хранилищу.
|
||||||
|
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
ids := []string{}
|
||||||
|
for _, record := range records {
|
||||||
|
for _, id := range record.TopicIDs {
|
||||||
|
if !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h.recordRepo.ResolveTopicNames(ownerID, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
|
||||||
|
topics := make([]string, 0, len(record.TopicIDs))
|
||||||
|
for _, id := range record.TopicIDs {
|
||||||
|
if name, ok := names[id]; ok {
|
||||||
|
topics = append(topics, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecordView{
|
||||||
|
ID: record.Id,
|
||||||
|
Title: record.Title,
|
||||||
|
OriginalFilename: record.OriginalFilename,
|
||||||
|
Brief: record.Brief,
|
||||||
|
Topics: topics,
|
||||||
|
State: record.State,
|
||||||
|
Halted: record.IsHalted(),
|
||||||
|
HaltReason: record.HaltReason,
|
||||||
|
DurationMs: record.DurationMs,
|
||||||
|
SizeBytes: record.SizeBytes,
|
||||||
|
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||||||
|
AvailableViews: views,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// availableViews — какие виды текста у записи есть **сейчас**.
|
||||||
|
//
|
||||||
|
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
|
||||||
|
// достижимо, потому что шаг завершения пишет их несколькими операциями.
|
||||||
|
//
|
||||||
|
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||||||
|
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||||||
|
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||||||
|
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
|
||||||
|
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
|
||||||
|
// из которой текст «вот-вот появится».
|
||||||
|
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||||
|
views := []string{}
|
||||||
|
|
||||||
|
if h.hasText(record.TranscriptTextID) {
|
||||||
|
views = append(views, entity.TextViewTranscript)
|
||||||
|
}
|
||||||
|
if h.hasText(record.LiteraryTextID) {
|
||||||
|
views = append(views, entity.TextViewLiterary)
|
||||||
|
}
|
||||||
|
if h.hasReplicas(record.StructureID) {
|
||||||
|
views = append(views, entity.TextViewReplicas)
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||||||
|
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||||||
|
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
|
||||||
|
// журналом, который пишет чтение текста.
|
||||||
|
func (h *AppHandler) hasText(textID *string) bool {
|
||||||
|
if textID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text, err := h.textRepo.GetByID(*textID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read text while listing views", "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return text.Contents != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) hasReplicas(structureID *string) bool {
|
||||||
|
if structureID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
structure, err := h.structureRepo.GetByID(*structureID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read structure while listing views", "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(structure.Replicas) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
|
||||||
|
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||||||
|
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||||||
|
//
|
||||||
|
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
|
||||||
|
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
|
||||||
|
// ключ уезжает параметром, а не телом.
|
||||||
|
func encodeCursor(c *contract.RecordCursor) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor is not decodable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||||||
|
if !ok || createdAt == "" || id == "" {
|
||||||
|
return nil, errors.New("malformed cursor")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
|
||||||
|
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
|
||||||
|
// обращает условие в постоянную истину или ложь — человек получает либо
|
||||||
|
// пустой архив при непустом счётчике, либо ленту с начала.
|
||||||
|
parsed, err := types.ParseDateTime(createdAt)
|
||||||
|
if err != nil || parsed.IsZero() {
|
||||||
|
return nil, errors.New("cursor carries no readable time")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||||
|
// всех одна, а сказать человеку надо разное.
|
||||||
|
func errWithMessage(base error, message string) error {
|
||||||
|
return &messagedError{base: base, message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagedError struct {
|
||||||
|
base error
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *messagedError) Error() string { return e.message }
|
||||||
|
func (e *messagedError) Unwrap() error { return e.base }
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
|
||||||
|
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||||
|
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||||
|
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||||
|
const (
|
||||||
|
CodeUnauthorized = "unauthorized"
|
||||||
|
CodeForbidden = "forbidden"
|
||||||
|
CodeNotFound = "not_found"
|
||||||
|
CodeBadRequest = "bad_request"
|
||||||
|
CodeTooLarge = "too_large"
|
||||||
|
CodeNotReady = "not_ready"
|
||||||
|
CodeTooManyRequests = "too_many_requests"
|
||||||
|
CodeInternal = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorBody — единая форма тела отказа на всех адресах приложения.
|
||||||
|
//
|
||||||
|
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||||
|
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||||
|
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||||||
|
// журнале владельца сервиса.
|
||||||
|
//
|
||||||
|
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||||
|
// числом, а не пересказать его словами.
|
||||||
|
type ErrorBody struct {
|
||||||
|
Code string `json:"error_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Limit *int64 `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||||
|
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||||
|
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||||
|
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||||||
|
// второе не говорило ему ничего.
|
||||||
|
//
|
||||||
|
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||||
|
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||||
|
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
|
||||||
|
func mapDomainError(err error) (int, ErrorBody) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, contract.ErrBadRequest):
|
||||||
|
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
|
||||||
|
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
|
||||||
|
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
|
||||||
|
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
|
||||||
|
// а не библиотека.
|
||||||
|
message := "Запрос составлен неверно"
|
||||||
|
var owned *messagedError
|
||||||
|
if errors.As(err, &owned) {
|
||||||
|
message = owned.message
|
||||||
|
}
|
||||||
|
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrRecordUnreadable):
|
||||||
|
return http.StatusBadRequest, ErrorBody{
|
||||||
|
Code: CodeBadRequest,
|
||||||
|
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrRecordTooLarge):
|
||||||
|
limit := entity.MaxRecordSize
|
||||||
|
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||||
|
Code: CodeTooLarge,
|
||||||
|
Message: "Запись больше допустимого размера",
|
||||||
|
Limit: &limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrTextNotReady):
|
||||||
|
return http.StatusConflict, ErrorBody{
|
||||||
|
Code: CodeNotReady,
|
||||||
|
Message: "Текст этого вида для записи ещё не готов",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrNotFound):
|
||||||
|
message := "Адрес не найден"
|
||||||
|
var owned *messagedError
|
||||||
|
if errors.As(err, &owned) {
|
||||||
|
message = owned.message
|
||||||
|
}
|
||||||
|
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrUnauthorized):
|
||||||
|
return http.StatusUnauthorized, ErrorBody{
|
||||||
|
Code: CodeUnauthorized,
|
||||||
|
Message: "Требуется вход",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrOwnerRequired):
|
||||||
|
return http.StatusForbidden, ErrorBody{
|
||||||
|
Code: CodeForbidden,
|
||||||
|
Message: "У вашей сессии нет учётной записи пользователя",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||||
|
// ответов иначе перебирается список заведённых записей.
|
||||||
|
var notFound *contract.JobNotFoundError
|
||||||
|
if errors.As(err, ¬Found) {
|
||||||
|
return http.StatusNotFound, ErrorBody{
|
||||||
|
Code: CodeNotFound,
|
||||||
|
Message: "Запись не найдена",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusInternalServerError, ErrorBody{
|
||||||
|
Code: CodeInternal,
|
||||||
|
Message: "Внутренняя ошибка сервиса",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||||
|
// уходит наружу с адресов приложения.
|
||||||
|
func fail(e *core.RequestEvent, err error) error {
|
||||||
|
status, body := mapDomainError(err)
|
||||||
|
return e.JSON(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
||||||
|
//
|
||||||
|
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
||||||
|
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
||||||
|
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
||||||
|
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
||||||
|
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
||||||
|
//
|
||||||
|
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
||||||
|
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
||||||
|
// «внутренняя ошибка» вместо предела числом.
|
||||||
|
//
|
||||||
|
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
||||||
|
// него, включая предел тела и ограничитель частоты.
|
||||||
|
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
||||||
|
return &hook.Handler[*core.RequestEvent]{
|
||||||
|
Id: "transcriberOneErrorForm",
|
||||||
|
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
||||||
|
Func: func(e *core.RequestEvent) error {
|
||||||
|
err := e.Next()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
||||||
|
// форма уже ушла в ответ, и сюда доходит только чужая.
|
||||||
|
var apiErr *router.ApiError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, translated := translateAPIError(apiErr)
|
||||||
|
return e.JSON(status, translated)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
||||||
|
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
||||||
|
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||||
|
switch apiErr.Status {
|
||||||
|
case http.StatusRequestEntityTooLarge:
|
||||||
|
limit := entity.MaxRecordSize
|
||||||
|
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||||
|
Code: CodeTooLarge,
|
||||||
|
Message: "Запись больше допустимого размера",
|
||||||
|
Limit: &limit,
|
||||||
|
}
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return http.StatusTooManyRequests, ErrorBody{
|
||||||
|
Code: CodeTooManyRequests,
|
||||||
|
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||||
|
}
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return http.StatusNotFound, ErrorBody{
|
||||||
|
Code: CodeNotFound,
|
||||||
|
Message: "Адрес не найден",
|
||||||
|
}
|
||||||
|
case http.StatusUnauthorized:
|
||||||
|
return mapDomainError(contract.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiErr.Status, ErrorBody{
|
||||||
|
Code: CodeInternal,
|
||||||
|
Message: "Внутренняя ошибка сервиса",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireUser — слой предъявления адресов приложения.
|
||||||
|
//
|
||||||
|
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
||||||
|
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
||||||
|
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
||||||
|
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
||||||
|
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
||||||
|
// записи он стать не может.
|
||||||
|
//
|
||||||
|
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||||
|
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||||
|
// уложенный файл — чего сервис не умеет вовсе.
|
||||||
|
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||||
|
return &hook.Handler[*core.RequestEvent]{
|
||||||
|
Id: "transcriberRequireUser",
|
||||||
|
// Сразу после слоя, который читает предъявленный токен: раньше него
|
||||||
|
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
|
||||||
|
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
|
||||||
|
Func: func(e *core.RequestEvent) error {
|
||||||
|
if e.Auth == nil {
|
||||||
|
return fail(e, contract.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||||||
|
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||||||
|
// предъявитель себя предъявил.
|
||||||
|
if e.Auth.Collection().Name != usersCollection {
|
||||||
|
return fail(e, contract.ErrOwnerRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
|
||||||
|
"название чужой темы наружу не выходит")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TranscribeHandler struct {
|
|
||||||
recordRepo contract.AudioRecordRepository
|
|
||||||
textRepo contract.TextRepository
|
|
||||||
trsService *service.TranscribeService
|
|
||||||
logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTranscribeHandler(
|
|
||||||
recordRepo contract.AudioRecordRepository,
|
|
||||||
textRepo contract.TextRepository,
|
|
||||||
trsService *service.TranscribeService,
|
|
||||||
logger *slog.Logger,
|
|
||||||
) *TranscribeHandler {
|
|
||||||
if logger == nil {
|
|
||||||
logger = slog.Default()
|
|
||||||
}
|
|
||||||
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateTranscribeJobResponse struct {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
State string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTranscribeJobResponse — ответ об одной записи.
|
|
||||||
//
|
|
||||||
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
|
|
||||||
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
|
|
||||||
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
|
|
||||||
// это объявленная ломка.
|
|
||||||
//
|
|
||||||
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
|
|
||||||
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
|
|
||||||
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
|
|
||||||
type GetTranscribeJobResponse struct {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
State string `json:"status"`
|
|
||||||
Halted bool `json:"halted"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
TranscriptionText *string `json:"transcription_text,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
|
||||||
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
|
||||||
// сохранены — публичный контракт HTTP API объявлен необратимым.
|
|
||||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
|
||||||
api := r.Group("/api")
|
|
||||||
|
|
||||||
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
|
|
||||||
// проверкой и действует только здесь: собственная поверхность хранилища под
|
|
||||||
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
|
|
||||||
// не шлёт.
|
|
||||||
api.Bind(SessionFromCookie())
|
|
||||||
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
|
|
||||||
// пускает всякую учётную запись хранилища, включая владельца панели, — а
|
|
||||||
// записи в коллекции пользователей у него нет, и владельцем записи он стать
|
|
||||||
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
|
|
||||||
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
|
|
||||||
// сервис не умеет вовсе.
|
|
||||||
api.Bind(apis.RequireAuth(migrations.UsersCollection))
|
|
||||||
|
|
||||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
|
||||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
|
||||||
// поэтому предел тела равен потолку самой записи.
|
|
||||||
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
|
||||||
api.GET("/status/{id}", h.GetTranscribeJobStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|
||||||
// Получаем файл из формы
|
|
||||||
file, header, err := e.Request.FormFile("audio")
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
h.logger.Error("Failed to close uploaded file", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
|
||||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
|
||||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
|
||||||
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
|
|
||||||
// сессия) при этом сохраняются, теряется только отмена.
|
|
||||||
ctx := context.WithoutCancel(e.Request.Context())
|
|
||||||
|
|
||||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
|
||||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
|
||||||
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
|
|
||||||
// уже есть и принадлежит коллекции пользователей.
|
|
||||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
|
||||||
if err != nil {
|
|
||||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
|
||||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
|
||||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Возвращаем успешный ответ
|
|
||||||
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
|
||||||
JobID: record.Id,
|
|
||||||
State: record.State,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
|
||||||
recordID := e.Request.PathValue("id")
|
|
||||||
|
|
||||||
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
|
|
||||||
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
|
||||||
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
|
||||||
// список заведённых записей.
|
|
||||||
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
|
||||||
if err != nil {
|
|
||||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
|
||||||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
|
||||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
|
||||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
|
||||||
// аварии не узнаёт ниоткуда.
|
|
||||||
var notFound *contract.JobNotFoundError
|
|
||||||
if !errors.As(err, ¬Found) {
|
|
||||||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
|
||||||
}
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
response := GetTranscribeJobResponse{
|
|
||||||
JobID: record.Id,
|
|
||||||
State: record.State,
|
|
||||||
Halted: record.IsHalted(),
|
|
||||||
CreatedAt: record.CreatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вид текста называется **явно**: видов у записи больше одного, и отдача
|
|
||||||
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
|
|
||||||
// состояния записи. В это поле уходит сырая расшифровка, и только она.
|
|
||||||
if record.TranscriptTextID != nil {
|
|
||||||
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
|
|
||||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
|
|
||||||
}
|
|
||||||
if text.Contents != "" {
|
|
||||||
contents := text.Contents
|
|
||||||
response.TranscriptionText = &contents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
"сто управляющих знаков не откусили сто знаков имени")
|
||||||
|
}
|
||||||
@@ -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 — предел на своей работе.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 приводит расширение к виду, годному для метки метрики.
|
||||||
//
|
//
|
||||||
// Расширение приходит из имени, которое дал отправитель, и потому может быть
|
// Расширение приходит из имени, которое дал отправитель, и потому может быть
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "сам префикс без имени не портится",
|
||||||
|
|||||||
@@ -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` с одним телом.
|
||||||
|
- Карточка остановленной записи несёт рубеж, признак остановки и её причину, а
|
||||||
|
машинного текста отказа не несёт. Оракул — тест на остановленной записи.
|
||||||
@@ -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** адреса почты в ответе нет
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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** управляющих знаков в нём нет
|
||||||
|
|
||||||
|
|||||||
@@ -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** в журнале владельца сервиса есть запись об остановке с причиной
|
||||||
|
|
||||||
|
|||||||
@@ -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: Расшифровка лежит своей строкой
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user