- json-api-for-spa: адреса приложения уехали в своё пространство /app/, приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены список, карточка, текст, /app/me и /app/config; имя файла отправителя легло своей колонкой рядом с заголовком - три действия над записью — правка заголовка, возврат в работу и журнал событий — собраны задачей audiorecord-actions - голова очереди: контракт, каркас, экран загрузки, список, действия - в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
18 KiB
✨ Свести приём и чтение записей к одному контракту для приложения
- Тип: feature
- Категория: Очередь — Проект экранов и адресов согласован с владельцем 2026-08-15: на этом контракте стоят каркас приложения и все его экраны, а стоящие выше правки входа и хранилища его не подпирают
- Зачем: Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
Экраны заводят задачу, видят её состояние и листают список — всё через один контракт.
Обработчик GET /api/status/:id сегодня отвечает 404 на любую ошибку
чтения, включая сбой базы, а POST /api/audio — 500 на любую ошибку заведения,
включая негодный файл. Экран, построенный на таком контракте, показывает «не
найдено» при упавшей базе.
Отдельная ветка того же класса найдена ревью задачи remove-telegram-intake
2026-08-15, проход сверки требований: приём отвергает пустого владельца своей
ошибкой, а транспорт переводит любую ошибку службы в 500. Спека приёма при
этом держит эту проверку ради понятного отказа — «отвечает отправителю понятным
отказом до того, как запись попадёт в память». Сегодня путь недостижим: слой
предъявления отвергает такого вызывающего раньше. Он становится достижимым вместе
с личными токенами и экранами приложения, то есть ровно здесь.
Состав контракта владелец согласовал 2026-08-15, разобрав его вместе с экранами приложения. Четыре развилки закрыты решением:
- Опрос
GET /api/status/:idубирается целиком, а с ним и имена его полей:job_idзовётсяid, текст переезжает на свой адрес. Стадия проекта — стройка, данных на сервере нет, а внешней программы на прежнем контракте не существует: своего токена у неё не было, и держать два адреса на один вопрос не за чем. Ломка объявляется в спекеintake. - Запись подписывается именем файла, данным отправителем. Имя ложится
своей колонкой —
original_filename, — а колонка заголовка остаётся под название, которое дал человек либо посчитала языковая модель. Одной колонкой на оба смысла посчитанный заголовок затирал бы то, по чему человек узнаёт свою запись, и вернуть затёртое было бы неоткуда. Ответ несёт оба поля, а экран показывает заголовок и подставляет имя файла, пока заголовка нет. Нормаintakeсужается: имя не доходит до имени файла в хранилище и до журнала, а в самой записи его видит один владелец. Отсюда обязанность приёма: длину ограничить, управляющие знаки убрать. - Аудио отдаётся своим адресом, а не ссылкой хранилища с токеном файла:
норма
storageзапрещает ссылке выходить в ответ отправителю. - Текст едет отдельным адресом, а не вместе с карточкой: шестичасовая расшифровка иначе задерживает показ шапки записи на мобильной сети.
Адреса приложения после этого такие:
| Метод и путь | Что отдаёт |
|---|---|
GET /app/me |
кто вошёл |
GET /app/config |
чем сервер ограничивает: потолок размера записи, частота опроса, перечень известных расширений, потолок тем |
POST /app/audiorecords |
список заведённых записей |
GET /app/audiorecords |
страницу своих записей, новые сверху |
GET /app/audiorecords/{id} |
карточку записи без текста |
GET /app/audiorecords/{id}/text |
текст названного вида — сплошной либо репликами со временем |
GET /app/config заводится здесь же, а не задачей экрана: пределы объявляет
сервер, и экран, знающий их своей константой, расходится с ним молча — до
первого отказа на гигабайтном файле, который человек уже успел отправить.
Приём стоит тем же адресом, что и список, и отличается только методом: он
заводит аудиозапись, а не кладёт файл. Прежнее имя audio называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь —
хотя запись он и создаёт.
Элемент списка несёт идентификатор, заголовок, имя файла отправителя, краткое
описание, темы, рубеж, признак остановки и её причину, длительность, размер и
время заведения.
Длительность с размером лежат сегодня строкой файла, а список по норме storage
читается без содержимого — значит оба переезжают колонками записи и заполняются
приёмом, который читает их у ffprobe и так.
Адреса записи названы audiorecords — по центральной сущности сервиса, а не
общим словом «записи»: последнее в хранилище значит строку любой коллекции.
Коллекция при этом зовётся audio_records, и расхождение намеренное: в схеме
имена в snake_case, у соседей по адресу — слитно.
Приложение целиком уезжает из /api/ в своё пространство /app/ — решение
владельца 2026-08-15. Пространство /api/ принадлежит хранилищу: оно вешает
туда двенадцать наборов своих адресов (settings, collections, files,
logs, realtime, batch, backups, crons, health, sql,
oauth2-redirect и собственные адреса входа коллекций), и поменять этот префикс
нельзя — он литерал в apis/base.go, а не настройка. Свободных имён сегодня
хватает, но соседство остаётся: обновление библиотеки вправе занять новое имя
рядом с нашим, и разойдутся они молча. Прецедент в проекте уже принят — адреса
входа вынесены на /auth/* этим же доводом (спека access).
Цена переезда названа: правило неизвестного пути перечисляет теперь четыре корня
— /api/, /app/, /auth/ и /_/ — вместо одного, а ограничитель частоты
хранилища, настроенный на /api/, наших адресов больше не покрывает, и своё
правило под /app/ заводится здесь же.
Настройки пользователя лягут на /app/me/settings; собственный /api/settings
принадлежит хранилищу и остаётся ему.
Затрагивает
POST /app/audiorecords— переезд приёма из/api/audio, коды ответа, форма ошибки и форма успешного ответа;GET /api/status/:id— убирается вместе со своими именами полей;GET /app/me— кто вошёл;GET /app/config— пределы и свойства сервера для экранов;GET /app/audiorecords— список своих записей с постраничным чтением и отбором по тому, в работе запись или нет;GET /app/audiorecords/{id}иGET /app/audiorecords/{id}/text— карточка и текст названного вида, сплошной либо репликами со временем;- слой предъявления сессии и своё правило ограничителя частоты — оба переезжают с адресов на новый корень;
- колонки
original_filename, длительности и размера у аудиозаписи, и шаг схемы под них; internal/entityи три места правки колонок вinternal/adapter/repo/pocketbase— компилятор их расхождения не видит (инвариант «Колонки записи правятся в двух местах» в CLAUDE.md);- приём: имя файла отправителя в свою колонку, с ограничением длины и уборкой управляющих знаков;
- единая точка отображения доменной ошибки в код ответа — её сегодня нет (conventions/errors.md);
internal/contract— доменные ошибки под отображение, отбор списка по владельцу;internal/controller/httpцеликом;- спека
intake— новый адрес приёма, убранный опрос готовности и суженная норма про имя отправителя; - спека
access— область слоя предъявления сессии названа там адресами приложения; docs/conventions/web-ui.md— правило неизвестного пути перечисляет корни сервиса, а не один/api/;docs/architecture.md, раздел «Единые точки проекта»;docs/database.md— новые колонки записи.
Критерии приёмки
- Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при
чтении даёт
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сплошным; он же репликами со временем; прежние адресаGET /api/status/{id}иPOST /api/audioна заведённой записи отвечают404.
Рамки
Аутентификацию и владельца не заводим — это oidc-login и record-ownership;
задача про форму контракта. Публичный контракт после мерджа обратной правкой не
откатывается, и по решению владельца от 2026-08-12 форма ответа приёма
согласуется здесь один раз — сразу списком и с местом под признак повтора, —
чтобы dedup-by-content-hash, multi-file-upload и reject-oversized-recording
её не переписывали, а экран загрузки не переделывался под вторую форму. Приём
по-прежнему берёт из запроса один файл: меняется форма ответа, не число файлов.
Сюда же приехала опечатка transcibe в тексте ошибки приёма (нашёл проход review-specs на ревью change
2026-08-11-fix-http-handler-tests): в одиночку текст ошибки менять нельзя,
а здесь контракт переписывается целиком.
Экранов не делаем ни одного — их берут spa-skeleton и задачи экранов. Три
адреса контракт называет, но не реализует, потому что у каждого свой хозяин:
поток аудио GET /app/audiorecords/{id}/audio — play-recording-in-app, чтение и
запись настроек по адресу /app/me/settings — settings-screen, признак
повторного файла — dedup-by-content-hash. Заголовок и темы, которые считает
языковая модель, тоже не здесь: место под них в ответе есть, заполняет его
llm-insights-adapter. Имени файла отправителя он при этом не трогает — колонки
разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в
приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт
задача, которой это понадобится.