приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Приложение живёт своим пространством адресов, а не общим с хранилищем
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Переезд в `/app/`, а слой сессии — на корень»
|
||||
|
||||
## Решение
|
||||
|
||||
Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой
|
||||
предъявления сессии повешен на **группу корня**, а не на перечень адресов.
|
||||
|
||||
## Почему
|
||||
|
||||
Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы
|
||||
адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка.
|
||||
Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки
|
||||
вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом
|
||||
начнёт отвечать не тот обработчик.
|
||||
|
||||
Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*`
|
||||
решением от 2026-08-12.
|
||||
|
||||
Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом
|
||||
приложения, и забытый в нём адрес молча перестал бы принимать куку».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших
|
||||
адресов больше не задевает;
|
||||
- `+` новый адрес приложения получает слой предъявления по построению, а не по
|
||||
памяти того, кто его добавил;
|
||||
- `−` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо
|
||||
одного: `/api/`, `/app/`, `/auth/` и `/_/`;
|
||||
- `−` ограничитель частоты хранилища, настроенный на его собственный корень,
|
||||
наших адресов не покрывает — своё правило заводится нами, и его включение
|
||||
вводит в действие заодно умолчательные правила хранилища;
|
||||
- `−` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено
|
||||
стадией — на сервере данных нет, внешней программы на прежнем контракте не
|
||||
существует.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Страница архива задаётся ключом, а не номером
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Страница задаётся ключом, а не номером»
|
||||
|
||||
## Решение
|
||||
|
||||
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
|
||||
заведения и идентификатор». Номер страницы отвергнут.
|
||||
|
||||
## Почему
|
||||
|
||||
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
|
||||
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
|
||||
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
|
||||
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
|
||||
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
|
||||
котором записи нет.»
|
||||
|
||||
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
|
||||
между ними одним лишь временем не определён.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
|
||||
пропуска;
|
||||
- `+` порядок между записями с равным временем устойчив;
|
||||
- `−` экран с нумерацией страниц так не сделать — листать можно только
|
||||
«дальше». Архиву это не нужно;
|
||||
- `−` ключ приходит от клиента и потому разбирается: время приводится к виду
|
||||
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
|
||||
истину или ложь.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Три новых колонки записи и один шаг схемы»
|
||||
|
||||
## Решение
|
||||
|
||||
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
|
||||
уже есть у строки её файла. Равенство между ними не поддерживается никем —
|
||||
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
|
||||
|
||||
## Почему
|
||||
|
||||
Обе величины показываются в списке, а список по норме `storage` читается без
|
||||
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
|
||||
держать равными. Решением владельца колонки остались, а равенство объявлено
|
||||
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
|
||||
файле — величины той копии, которой файл является сейчас». Уточнение
|
||||
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
|
||||
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
|
||||
«что лежит сейчас».
|
||||
|
||||
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
|
||||
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
|
||||
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
|
||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||
метаданными отвергается отказом и не заводится.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` страница списка не читает по строке файла на каждую запись;
|
||||
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
|
||||
- `−` в применённом шаге схемы навсегда остаются две колонки, повторяющие
|
||||
величины строки файла; расхождение между ними — не поломка, и заметить его
|
||||
нечем;
|
||||
- `−` запись, заведённая рукой в панели без величин, покажет человеку ноль.
|
||||
@@ -35,6 +35,9 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
|
||||
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
|
||||
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
|
||||
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
|
||||
|
||||
+20
-9
@@ -15,7 +15,7 @@
|
||||
[conventions/go-linters.md](conventions/go-linters.md).
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
|
||||
входов**: приём за сессией, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
@@ -38,6 +38,13 @@
|
||||
целиком и вложением, как из сохранённого строится структура реплик без
|
||||
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
|
||||
Задача `record-centric-model` 2026-08-14;
|
||||
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
|
||||
приложения**: пространство адресов `/app/` и единая форма отказа с
|
||||
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
|
||||
чтение — страница записей ключом, карточка без текста и текст названного вида.
|
||||
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
||||
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
||||
2026-08-15;
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
@@ -82,7 +89,7 @@
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
|
||||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
@@ -139,15 +146,15 @@
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
|
||||
остановленная запись отдаёт признак остановки. Владелец — по метрике
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||||
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||
@@ -177,11 +184,15 @@
|
||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
|
||||
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
|
||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
|
||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение
|
||||
доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик
|
||||
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -214,7 +225,7 @@
|
||||
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
|
||||
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
|
||||
компонентов.
|
||||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом.
|
||||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
|
||||
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
|
||||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||
|
||||
@@ -98,20 +98,36 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
|
||||
HTTP и веба:
|
||||
|
||||
| Доменная ошибка | Статус | Сообщение |
|
||||
| --- | --- | --- |
|
||||
| задача не найдена | 404 | «задача не найдена» |
|
||||
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
|
||||
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||||
| --- | --- | --- | --- |
|
||||
| сессии нет | 401 | `unauthorized` | «требуется вход» |
|
||||
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
|
||||
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||||
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
|
||||
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||||
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||||
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||||
|
||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||
|
||||
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
|
||||
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
|
||||
любую ошибку заведения задачи.
|
||||
**Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
|
||||
хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
|
||||
`400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
|
||||
был бы единственным оставшимся путём. Норму держит спека `archive`.
|
||||
|
||||
Точка живёт в `internal/controller/http.mapDomainError` и названа в
|
||||
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
|
||||
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
|
||||
`json-api-for-spa` 2026-08-15.
|
||||
|
||||
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||||
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
|
||||
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
|
||||
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
|
||||
приходил бы телом библиотеки.
|
||||
|
||||
### Разовый ответ и сохранённая диагностика
|
||||
|
||||
|
||||
+42
-2
@@ -69,6 +69,9 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты |
|
||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
|
||||
@@ -88,8 +91,39 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||
паузе и сроку протухания.
|
||||
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
|
||||
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
|
||||
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и
|
||||
`(owner, state, halted_at)` под отбор тремя состояниями.
|
||||
|
||||
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
||||
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
||||
страница сканировала таблицу целиком и досортировывала результат во временном
|
||||
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
|
||||
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
|
||||
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
|
||||
хранит их бессрочно.
|
||||
|
||||
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
|
||||
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
|
||||
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||
файла в хранилище и в журнал оно по-прежнему не идёт.
|
||||
|
||||
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||
приёмом один раз; на файле — величины нынешней копии файла.
|
||||
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы —
|
||||
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому,
|
||||
что показываются в списке, а список читается без содержимого. Решение владельца
|
||||
от 2026-08-15.
|
||||
|
||||
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.**
|
||||
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём.
|
||||
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел
|
||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||
метаданными отвергается отказом и не заводится вовсе.
|
||||
|
||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||
@@ -287,6 +321,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||
|
||||
+11
-9
@@ -92,16 +92,18 @@ Telegram.
|
||||
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
||||
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
||||
расшифровку.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||
сессией видит только записи того, чью сессию предъявила.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
|
||||
токеном, получает идентификатор записи и читает её карточку
|
||||
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только
|
||||
предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего
|
||||
входа у программы нет — его заводит `api-tokens`. Записи при этом
|
||||
разграничены: программа с чужой сессией видит только записи того, чью сессию
|
||||
предъявила.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и опрос готовности отдаёт этот признак
|
||||
тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
|
||||
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
получает признак остановки с причиной, и карточка записи отдаёт признак и
|
||||
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
|
||||
доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
|
||||
## Референсы
|
||||
|
||||
|
||||
@@ -172,6 +172,18 @@
|
||||
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||
рубеж — одним дескриптором
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
|
||||
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
|
||||
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
|
||||
2026-08-15 про единую форму отказа).
|
||||
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
|
||||
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
|
||||
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
|
||||
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
|
||||
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
|
||||
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
|
||||
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
|
||||
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||||
@@ -280,6 +292,29 @@ API и имя не откатываются обратной правкой по
|
||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||
оракул, и выдумывать оракул задним числом нельзя.
|
||||
|
||||
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
|
||||
|
||||
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
|
||||
`json-api-for-spa`
|
||||
- **Симптом:** три отказа под корнем приложения — превышение
|
||||
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
|
||||
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
|
||||
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
|
||||
заводилась
|
||||
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
|
||||
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
|
||||
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
|
||||
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
|
||||
все три случая как закрытые
|
||||
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
|
||||
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
|
||||
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
|
||||
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
|
||||
производителя в рабочем коде
|
||||
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
|
||||
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
|
||||
вопросом в раздел ниже
|
||||
|
||||
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
|
||||
|
||||
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
|
||||
|
||||
+14
-10
@@ -3,7 +3,7 @@
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
|
||||
без входа только проба здоровья и метрики. Находки строятся против этого —
|
||||
сегодняшнего — периметра.
|
||||
@@ -11,7 +11,7 @@
|
||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||||
задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем
|
||||
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
|
||||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||
доступа — страницы расхода для владельца сервиса.
|
||||
|
||||
@@ -46,9 +46,11 @@ Telegram — связи чата с учётной записью сервис
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
|
||||
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
|
||||
на распознавание столько, сколько захочет.
|
||||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
|
||||
распознавание столько, сколько захочет: ограничитель частоты под корнем
|
||||
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
|
||||
по-прежнему нет — её заводит `per-user-size-quota`.
|
||||
|
||||
## Недоверенный вход
|
||||
|
||||
@@ -56,8 +58,10 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
@@ -81,8 +85,8 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
|
||||
готовности и в панели владельца.
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||
и в панели владельца.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -130,7 +134,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
что защищает `GET /api/status/:id`.
|
||||
что защищает карточку записи и её текст.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||
|
||||
Reference in New Issue
Block a user