закрыта задача json-api-for-spa

This commit is contained in:
av
2026-08-15 13:52:06 +03:00
parent 3a2da3004b
commit a5bc322814
2 changed files with 0 additions and 185 deletions
-1
View File
@@ -43,7 +43,6 @@
## Очередь ## Очередь
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. - [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. - [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
-184
View File
@@ -1,184 +0,0 @@
# ✨ Свести приём и чтение записей к одному контракту для приложения
- **Тип:** 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`. Имени файла отправителя он при этом не трогает — колонки
разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в
приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт
задача, которой это понадобится.