хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
+33 -18
View File
@@ -11,8 +11,9 @@
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач
и метаданные файлов лежат в SQLite, файлы — на диске.
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
панель администратора.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -23,10 +24,10 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек
Go 1.24 (нужен CGO из-за `mattn/go-sqlite3`), gin, goqu, goose, SQLite,
`go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex
SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker,
выкладка — Ansible из `pet-project-server`.
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Инварианты
@@ -40,8 +41,9 @@ SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Dock
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical**
*Изъятие:* расширение — хвост после последней точки — в журнал попадает, потому
что стоит в собственном имени файла на диске, а путь логируется. Изъятие узкое
*Изъятие:* расширение — хвост после последней точки — в журнал попадает
собственным полем: по нему прослеживается путь записи. Имени файла в журнале
нет вовсе (инвариант ниже). Изъятие узкое
и кончается журналом: наружу, меткой метрики, расширение выходит только
приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
@@ -58,15 +60,28 @@ SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Dock
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом. Необратимо: goose считает применённую версию по номеру. **critical**
- **Новая колонка правится во всех четырёх местах** репозитория SQLite —
`Create`, `Save`, `GetByID`, `FindAndAcquire`. Компилятор расхождение не
поймает, а проявится оно как потерянное при сохранении поле. **major**
файлом шага. Необратимо: хранилище считает применённое по имени файла.
**critical**
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
поле теряется **только у задачи, попавшей к воркеру**. **major**
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
достался другому, завершается без записи и без ответа отправителю. Иначе два
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
**major**
## Команды
```bash
go build ./... # нужен CGO
go build ./... # CGO не нужен
go test ./...
go vet ./...
gofmt -l .
@@ -118,10 +133,10 @@ task gate # весь набор проверок разом
## Запреты
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
Локальная база в `./data/` — своя, её ронять и пересоздавать можно свободно.
- **Боевой каталог записей не трогать.** `data/files` на сервере: там лежат
голосовые сообщения живых людей.
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
@@ -139,7 +154,7 @@ task gate # весь набор проверок разом
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка `data/files`, публичный контракт HTTP API, имя
файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до