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

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+42 -2
View File
@@ -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 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |