хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+33 -50
View File
@@ -2,14 +2,16 @@
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём записи: что считается принятой записью, что уезжает в ответ и что
происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из
них сервис вправе подняться. Исход принятой записи её владелец узнаёт карточкой —
норму держит `archive`, и опрос готовности убран 2026-08-15.
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
возвращение заводит их заново, вместе со связью чата и учётной записи.
## Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
@@ -19,9 +21,7 @@
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
методом: он **заводит аудиозапись**, а не кладёт файл.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
@@ -34,10 +34,8 @@
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться:
идентификатор записи зовётся `id`.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
@@ -46,9 +44,7 @@
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
ветвями.
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
@@ -56,25 +52,20 @@
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку каталога
данных нормирует capability `storage`.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя.
Обязательность владельца при этом MUST держаться и схемой: колонка владельца
пустого значения не принимает вовсе, и норму эту держит capability `storage`.
Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом
до того, как запись попадёт в память, а схема отвечала бы отказом сохранения
после укладки файла.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание
заводит учётную запись само, а предъявителя с собственным токеном хранилища не
существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим
единственным случаем.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
@@ -88,16 +79,9 @@
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** содержимое записи целиком лежит в каталоге данных одним файлом
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
@@ -123,8 +107,8 @@
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и
содержимым своим приёму не подконтрольно.
отправителем, MUST не попадать **ни в имя файла на диске, ни в путь к нему**: оно
приходит извне и содержимым своим приёму не подконтрольно.
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
@@ -132,27 +116,27 @@
подписывает запись».
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
файла на диске расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширениемхвостом после
последней точки.
Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой
библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и
правило перестало быть отменой чужого поведенияоно стало прямым описанием
своего.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
- **THEN** имя файла на диске оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
- **THEN** имя файла на диске оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
#### Scenario: Имя отправителя в имя файла не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **THEN** имя файла на диске не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных
@@ -334,4 +318,3 @@ MUST ограничивать его длину и MUST убирать из не
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет