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

185 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✨ Свести приём и чтение записей к одному контракту для приложения
- **Тип:** 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](../../CLAUDE.md));
- приём: имя файла отправителя в свою колонку, с ограничением длины и уборкой
управляющих знаков;
- единая точка отображения доменной ошибки в код ответа — её сегодня нет
([conventions/errors.md](../../docs/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`. Имени файла отправителя он при этом не трогает — колонки
разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в
приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт
задача, которой это понадобится.