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

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
@@ -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` с одним телом.
- Карточка остановленной записи несёт рубеж, признак остановки и её причину, а
машинного текста отказа не несёт. Оракул — тест на остановленной записи.