Files
transcriber/tasks/items/json-api-for-spa.md
T
av 79ff12548f tasks: спроектирован контракт приложения и переставлена голова очереди
- json-api-for-spa: адреса приложения уехали в своё пространство /app/,
  приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены
  список, карточка, текст, /app/me и /app/config; имя файла отправителя
  легло своей колонкой рядом с заголовком
- три действия над записью — правка заголовка, возврат в работу и журнал
  событий — собраны задачей audiorecord-actions
- голова очереди: контракт, каркас, экран загрузки, список, действия
- в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
2026-08-15 09:06:11 +03:00

18 KiB
Raw Blame History

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

  • Тип: feature
  • Категория: Очередь — Проект экранов и адресов согласован с владельцем 2026-08-15: на этом контракте стоят каркас приложения и все его экраны, а стоящие выше правки входа и хранилища его не подпирают
  • Зачем: Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.

Экраны заводят задачу, видят её состояние и листают список — всё через один контракт.

Обработчик GET /api/status/:id сегодня отвечает 404 на любую ошибку чтения, включая сбой базы, а POST /api/audio500 на любую ошибку заведения, включая негодный файл. Экран, построенный на таком контракте, показывает «не найдено» при упавшей базе.

Отдельная ветка того же класса найдена ревью задачи 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}/audioplay-recording-in-app, чтение и запись настроек по адресу /app/me/settingssettings-screen, признак повторного файла — dedup-by-content-hash. Заголовок и темы, которые считает языковая модель, тоже не здесь: место под них в ответе есть, заполняет его llm-insights-adapter. Имени файла отправителя он при этом не трогает — колонки разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт задача, которой это понадобится.