- один факт — один дом: рецепт локального входа, правило чтения X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к своим домам, копии заменены ссылками - форма [auth.test_headers] выровнена по образцу конфига в семи местах; сценарии intake и archive перестали ссылаться на сессию, которой сервис не выдаёт - поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку адреса пира зовут трое, а не двое; обзор capability access знает о задаче 2026-08-23
426 lines
51 KiB
Markdown
426 lines
51 KiB
Markdown
# Архитектура
|
||
|
||
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
|
||
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
|
||
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
||
|
||
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
||
[passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
|
||
этого ещё не решено — в разделе «Открытые вопросы».
|
||
|
||
Заведённые capability нормируют **поведение сервиса** для его потребителей —
|
||
все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
|
||
у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
|
||
от 2026-08-13 его нормы живут в самих шагах, их проверках и
|
||
[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,
|
||
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||
2026-08-14, `storage-without-pocketbase` 2026-08-22;
|
||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||
повтором и молчание конвейера наружу: задачи
|
||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
|
||
контекста посреди шага остаются
|
||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
|
||
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
|
||
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
|
||
панель владельца исчезла и не заменена ничем;
|
||
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
||
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||
целиком и вложением, как из сохранённого строится структура реплик без
|
||
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
|
||
Задача `record-centric-model` 2026-08-14;
|
||
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
|
||
приложения**: пространство адресов `/app/` и единая форма отказа с
|
||
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
|
||
чтение — страница записей ключом, карточка без текста и текст названного вида.
|
||
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
||
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
||
2026-08-15;
|
||
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
|
||
сервис его отдаёт, каким адресом оно открывается, что делает обновление
|
||
страницы посреди него и что человек видит, открыв его. Здесь же правило
|
||
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||
его дальше: узнавание по заголовку доверенного источника, заведение учётной
|
||
записи первым обращением и то, какие адреса остаются открытыми. Собственный
|
||
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
|
||
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
|
||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
|
||
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
|
||
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]` — с
|
||
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
|
||
`config-test-headers-login` 2026-08-23; решения —
|
||
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
|
||
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||
|
||
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
|
||
Задача, которая его трогает, дописывает спеку своей capability.
|
||
|
||
## Принципы
|
||
|
||
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
|
||
захвата и порядок выборки нормирует
|
||
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||
кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
|
||
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
|
||
одно слово — таблица перестала быть коллекцией.
|
||
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
|
||
задача возвращается в работу, нормирует
|
||
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
|
||
код или оснастка; в боевом бинарнике он появляется только отдельным решением
|
||
владельца и только под ключом, названным своим предметом: имитацию заголовков
|
||
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
|
||
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
|
||
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
|
||
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
|
||
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
|
||
**закрытый** перечень следствий, и перечень этот ведёт спека
|
||
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
|
||
только подстановку заголовков». Новое следствие вешается на ключ только новым
|
||
требованием той же спеки.
|
||
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
|
||
слой не знает внешнего никогда. `internal/service` знает только
|
||
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
|
||
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
||
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
|
||
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
|
||
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
|
||
и правило на это направление заведено впервые.
|
||
|
||
## Слои и модель домена
|
||
|
||
**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает
|
||
ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах
|
||
и реализацией в инфраструктуре.
|
||
|
||
| Слой | Дом | Что живёт | Чего не знает |
|
||
| --- | --- | --- | --- |
|
||
| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` |
|
||
| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций |
|
||
| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов |
|
||
| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов |
|
||
| Входы | `internal/controller` | HTTP и пул воркеров | друг друга |
|
||
| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — |
|
||
|
||
Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты
|
||
самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а
|
||
импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча.
|
||
|
||
**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь
|
||
вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:
|
||
|
||
- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение
|
||
(`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе;
|
||
- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и
|
||
журнал событий принадлежат записи и живут её идентификатором, а правит агрегат
|
||
держатель захвата;
|
||
- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`,
|
||
`entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей
|
||
идентичности не имеют;
|
||
- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей
|
||
рукой и чем кончилось;
|
||
- **репозиторий** — интерфейсы `internal/contract`, реализации под
|
||
`internal/adapter/repo`;
|
||
- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией
|
||
пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`);
|
||
- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит
|
||
согласованным, а не заполняется полями снаружи.
|
||
|
||
**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в
|
||
домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения
|
||
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
||
полями, принадлежит `internal/entity`.
|
||
|
||
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
|
||
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
|
||
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
|
||
пришедшего приходит ему интерфейсом `contract.UserRepository`.
|
||
|
||
## Компоненты
|
||
|
||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||
|
||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
|
||
|
||
| Компонент | Где | Что делает |
|
||
| --- | --- | --- |
|
||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
|
||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
|
||
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
|
||
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
|
||
| Оснастка владельца | `cmd/devtools` | Возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
|
||
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
||
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||
|
||
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
||
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
||
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
|
||
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
|
||
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
|
||
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
|
||
настройкой.
|
||
|
||
## Внешние границы и форматы
|
||
|
||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||
`UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
|
||
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
|
||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
|
||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
|
||
нужен. База и файлы записей лежат под одним каталогом данных.
|
||
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
|
||
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
|
||
отбрасывает — наблюдение и чем оно проверено, в
|
||
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
|
||
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
|
||
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
|
||
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
|
||
берём сами.
|
||
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||
сетезависимый адрес набора проверок; все такие перечислены в
|
||
[CLAUDE.md](../CLAUDE.md), «Гейт».
|
||
|
||
## Эксплуатация
|
||
|
||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||
обратный прокси, который публикует HTTP-порт наружу.
|
||
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
|
||
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
|
||
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
|
||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||
сервис поднимает молча. Чем это обеспечено и как проверено —
|
||
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
|
||
свойством безопасно и обратное направление: прежний образ поднимается на
|
||
конфиге с ключами, которых он ещё не знает.
|
||
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
|
||
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
|
||
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
|
||
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
|
||
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
|
||
схемы нет и не планируется.
|
||
|
||
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
|
||
выкладки `record-centric-model` откат работал, после перестал, а с уходом
|
||
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
|
||
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
|
||
потому, что риск сегодня чем-то грозит.
|
||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||
|
||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
|
||
|
||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||
| --- | --- | --- | --- | --- |
|
||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
|
||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
|
||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||
|
||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
|
||
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||
контейнера. Отдельного оповещения нет.
|
||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
|
||
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
|
||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||
вхолостую с паузой из
|
||
[database.md](database.md), «Настройки с числовым значением».
|
||
|
||
## Единые точки проекта
|
||
|
||
| Что | Где |
|
||
| --- | --- |
|
||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
|
||
| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
|
||
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
|
||
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
|
||
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
|
||
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
|
||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
|
||
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
|
||
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
|
||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||
| Чтение времени | `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` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` |
|
||
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
|
||
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
|
||
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go` — `peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
|
||
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||
|
||
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
|
||
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
|
||
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
|
||
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
|
||
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
|
||
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
|
||
|
||
## Деплой
|
||
|
||
Образ собирается по контракту роли `app_image`: `task image` даёт
|
||
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
|
||
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
||
`inv pl -- transcriber` из `pet-project-server`.
|
||
|
||
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||
работает под непривилегированным пользователем `transcriber` — в образе он
|
||
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
|
||
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
|
||
разрешить его не может. Числа те же, что при заведении пользователя.
|
||
|
||
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
|
||
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
|
||
делать нечего.
|
||
|
||
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
|
||
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
|
||
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
|
||
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||
четыре места однажды, сколько бы подкоманд в нём ни завелось.
|
||
|
||
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
|
||
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
|
||
DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при
|
||
живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и
|
||
сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в
|
||
рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.
|
||
|
||
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
|
||
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
|
||
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
|
||
замерялось и замеряться не будет — решение владельца от 2026-08-15.
|
||
|
||
## Открытые вопросы
|
||
|
||
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
|
||
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
|
||
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
|
||
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
|
||
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
|
||
[access](../openspec/specs/access/spec.md), решение —
|
||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||
зависит возвращение убранного входа.
|
||
Второго периметра на порту сервиса при этом не осталось: панель администратора
|
||
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
|
||
больше нечего.
|
||
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||
Решено делать SPA,
|
||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||
сборкой Vite — 2026-08-11,
|
||
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
||
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
|
||
входит в гейт и слоем в сборку образа; как именно он зовётся — решением
|
||
[ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15.
|
||
Не решено, брать ли готовый набор компонентов.
|
||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
|
||
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
|
||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||
[security.md](security.md).
|
||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
|
||
`deferred-general` по длине не выяснены. Расчётные
|
||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||
записи живёт в хранилище»; откуда взято число —
|
||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
|
||
она осталась только этим числом.
|
||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||
обрыв начинает загрузку заново.
|
||
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
|
||
публичный контракт приёма и потому идёт через решение в `adr/`.
|
||
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
|
||
учёта по пользователям нет: метрики считают сервис целиком. Что именно
|
||
копится — записи о потреблении или счётчики — решает задача
|
||
`usage-accounting`.
|
||
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
|
||
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
|
||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
|
||
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
|
||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
|
||
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
|
||
на этом случае не проверялся.
|
||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
|
||
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
|
||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
||
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
||
паузы, а не замер
|
||
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
|
||
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
|
||
Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22
|
||
([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди
|
||
пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
|
||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||
выкладке сегодня нет.
|
||
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
||
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
||
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
|
||
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|