- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
321 lines
24 KiB
Markdown
321 lines
24 KiB
Markdown
# intake Specification
|
||
|
||
## Purpose
|
||
|
||
Приём записи: что считается принятой записью, что уезжает в ответ и что
|
||
происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из
|
||
них сервис вправе подняться. Исход принятой записи её владелец узнаёт карточкой —
|
||
норму держит `archive`, и опрос готовности убран 2026-08-15.
|
||
|
||
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
|
||
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
|
||
возвращение заводит их заново, вместе со связью чата и учётной записи.
|
||
## 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`.
|
||
Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом
|
||
до того, как запись попадёт в память, а схема отвечала бы отказом сохранения
|
||
после укладки файла.
|
||
|
||
Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание
|
||
заводит учётную запись само, а предъявителя с собственным токеном хранилища не
|
||
существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим
|
||
единственным случаем.
|
||
|
||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||
пишется.
|
||
|
||
#### Scenario: Запись принята
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **AND** отправитель узнан
|
||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
|
||
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
|
||
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
|
||
место под признак повторного файла
|
||
- **AND** содержимое записи целиком лежит в каталоге данных одним файлом
|
||
- **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`, чтобы у
|
||
файла на диске расширение было всегда.
|
||
|
||
Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой
|
||
библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и
|
||
правило перестало быть отменой чужого поведения — оно стало прямым описанием
|
||
своего.
|
||
|
||
#### 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** имени, под которым файл лёг в хранилище, в журнале приёма нет
|
||
|
||
### Requirement: Метка метрики несёт только известное расширение
|
||
|
||
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
|
||
употребить его меткой метрики: расширение приводится к нижнему регистру и
|
||
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
|
||
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
|
||
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
|
||
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
|
||
без расширения, и различать его от чужого хвоста метка обязана.
|
||
|
||
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
|
||
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
|
||
временных рядов, которым иначе распоряжается анонимный отправитель.
|
||
|
||
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
|
||
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
|
||
с ней.
|
||
|
||
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
|
||
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||
|
||
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
|
||
`other` в метке означает «расширение не из перечня», а само оно стоит полем
|
||
журнальной строки приёма и полем формата строки конвертации.
|
||
|
||
#### Scenario: Незнакомое расширение наружу не выходит
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||
принадлежит перечню известных форматов
|
||
- **THEN** метка метрики принимает значение `other`
|
||
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
||
|
||
#### Scenario: Известное расширение идёт как есть
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт запись с именем `sample.MP3`
|
||
- **THEN** метка метрики принимает значение `mp3`
|
||
|
||
### Requirement: Поднятые входы видны наблюдателю
|
||
|
||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
|
||
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
|
||
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
|
||
единица рядом с ним — как исправность того, чего нет.
|
||
|
||
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
|
||
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
|
||
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
|
||
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
|
||
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
|
||
неудачей чтения самих метрик.
|
||
|
||
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
|
||
сервиса станет больше одного.
|
||
|
||
#### Scenario: В метриках только оставшийся вход
|
||
|
||
- **GIVEN** сервис поднялся
|
||
- **WHEN** наблюдатель читает метрики
|
||
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
|
||
- **AND** метки убранного входа Telegram в метриках нет вовсе
|
||
|
||
### Requirement: Имя файла отправителя подписывает запись
|
||
|
||
Приём SHALL класть имя файла, данное отправителем, в собственную колонку
|
||
аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек
|
||
узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт
|
||
название, которое дал человек либо посчитала языковая модель, и одной колонкой на
|
||
оба смысла посчитанное название затирало бы то, по чему запись узнают, — а
|
||
вернуть затёртое было бы неоткуда.
|
||
|
||
Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не
|
||
сочиняет.
|
||
|
||
Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём
|
||
MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем
|
||
сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель.
|
||
|
||
Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет.
|
||
|
||
#### Scenario: Имя доходит до записи
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** он шлёт запись с именем `разговор.mp3`
|
||
- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3`
|
||
|
||
#### Scenario: Заголовок принятой записи пуст
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** он шлёт запись с именем `разговор.mp3`
|
||
- **THEN** колонка заголовка у заведённой записи пуста
|
||
|
||
#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
|
||
- **THEN** колонка имени файла несёт имя не длиннее предела
|
||
- **AND** управляющих знаков в нём нет
|