Compare commits

..
35 Commits
Author SHA1 Message Date
av c1dab24de1 учёт: закрыта задача drop-dead-dotenv-loader, заведена drop-dead-entrypoint-script 2026-08-23 21:14:30 +03:00
av b01bdabc37 снят мёртвый godotenv.Load() вместе с зависимостью
- переменных окружения наш рабочий код больше не читает нигде: ушли вызов, импорт и предупреждение на старте
- у оставленной строки .env в .gitignore и у запрета forbidigo переписаны причины: прежние называли живым снесённого читателя
- в документы записано, что барьеры против .env стоят без читателя, и правило парности .gitignore с .dockerignore
2026-08-23 21:06:30 +03:00
av 1385dd3f5d раскладка av-dev поднята до версии 5: метка прогона ушла из процесса
- docs/review.md: подраздел «Триггеры метки» заменён на «Когда звать глубокое
  ревью» — областями кода и перечнем необратимых мест проекта
- дубли инвариантов и перечня необратимого сведены к ссылкам на CLAUDE.md, а в
  database.md убрано разошедшееся число мест правки колонки
- шапка раздела перестала называть самый поздний артефакт ревью: строка
  протухала каждым прогоном
2026-08-23 20:11:48 +03:00
av 404be2bdd5 storage: заголовок требования о выдаче файла говорит то же, что тело
- «Файл отдаётся ссылкой» осталось от снятого решения: тело требования прямо
  отрицает выдачу значения на предъявителя, право даёт узнавание при каждом
  обращении
- слово «ссылка» перестало нести в одном требовании три смысла: указатель в
  базе, значение доступа и путь к файлу в журнале
2026-08-23 16:58:30 +03:00
av 31ce520c5b документы: сведены расхождения, найденные сверкой канона
- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
2026-08-23 16:41:06 +03:00
av 11269c1567 учёт: заведён урожай ревью входа по конфигу
- пять задач партии review-2026-08-23: многозначность Remote-Name и Remote-Email,
  мелочи входа, механизация правила о зависимостях конфига, снятие мёртвого
  читателя .env, управляющие знаки в расширении
- две существующие разведки дополнены находками того же прогона: адрес объекта в
  тексте отказа SpeechKit и выводимость имени копии из журнала
2026-08-23 14:03:52 +03:00
av 0aa9b3a567 security.md: защита от многозначного заголовка описана точнее
Отбой двух значений закрывает только Remote-User; Remote-Email берётся первым
и закрепляется за чужой учётной записью навсегда. Путь построен ревью
2026-08-23, правило распространяется на всю тройку отдельной задачей.
2026-08-23 13:41:53 +03:00
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00
av 75c6f0168a учёт: закрыт переезд хранилища, заведён урожай его ревью
- storage-without-pocketbase закрыта как реализованная: приёмка сошлась по всем
  пяти критериям записи и по двенадцати приёмочным свойствам рубрики ревью
  дизайна, работа лежит коммитом c9b7765
- урожай триажа ревью развёрнут в одиннадцать записей с тегом партии
  review-2026-08-23 и расставлен по зависимости, а не в конец списка
- находка про признак живости воркера слита в stalled-pipeline-metric: у неё та
  же причина — вставший конвейер неотличим от простоя
2026-08-23 08:41:49 +03:00
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00
av 1edf8cb225 записан подход к проектированию: чистая архитектура и тактические шаблоны DDD
- architecture.md: раздел «Слои и модель домена» — таблица слоёв с колонкой «чего не знает», шаблоны со своим сегодняшним предметом в коде, запрет анемичной модели
- CLAUDE.md: раздел «Проектирование» с подходом и ссылкой на раздел архитектуры
- названо, что чистоту домена не держит ни одно правило archrules
2026-08-22 21:05:13 +03:00
av ad5b5e377f записано решение уйти с PocketBase на SQLite со своим каталогом файлов
- разведка storage-without-pocketbase: шесть ролей библиотеки в этом коде, отпавший довод перевода, шесть модулей достижимы только через неё
- ADR-2026-08-22-storage-without-pocketbase заменяет три записи: перевод в PocketBase, очередь коллекцией, файл за защищённым полем
- задача storage-without-pocketbase встала первой строкой плана стройки
2026-08-22 21:00:14 +03:00
av a8fb4793be закрыта задача trusted-header-login
- две записи, ссылавшиеся на убранный oidc-login, переписаны
- dev-run-task переведена с отдельного cmd/devadmin на подкоманду cmd/devtools
2026-08-22 20:25:13 +03:00
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00
av e4441f3c49 tasks: вход переезжает на заголовки прокси, пять задач про OIDC закрыты
- заведена `trusted-header-login` и поставлена в голову очереди
- пять задач про механику OIDC закрыты как отменённые ею
- `dev-run-task` и `api-tokens` переписаны под новый вход
2026-08-22 17:43:18 +03:00
av 2bb252e018 tasks: владельца панели заводит отдельная команда, а не ключ конфига
- admin-owner-from-config закрыта: решение владельца 2026-08-15, ключ конфига
  не заводится, требование спеки storage остаётся в силе
- dev-run-task вобрала cmd/devadmin и стоит первой строкой: инструмент и шаг
  порознь не нужны, поэтому едут одной задачей
2026-08-15 20:49:50 +03:00
av 97c6c7c440 tasks: заведены задачи про владельца панели из конфига и команду локального запуска
- admin-owner-from-config первой строкой: сервис заводит владельца панели
  по ключу конфига; задача отменяет требование спеки storage, и это названо
  в рамках
- dev-run-task следом: task dev поднимает сервис и заглушку разом, гасит
  оба по Ctrl+C; зависит от ключа конфига
2026-08-15 20:39:45 +03:00
av daa4c3b6e4 oidcstub: записано решение не заводить заглушке своих тестов
- решение владельца 2026-08-15: пакет в образ не едет, отказ громкий,
  а путь входа проверен подставным провайдером в тестах транспорта
2026-08-15 20:18:58 +03:00
av 91138dd39a точки входа переехали в cmd/, заведена заглушка OIDC для локального входа
- main.go и journal_route_test.go переехали в cmd/transcriber без правок
  содержимого; образ собирает ./cmd/transcriber поимённо
- cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает
  и слушает петлевой адрес: войти без Authelia стало чем
- ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа
  на AAAA, которого нет, и npm ci висел вместо отказа
2026-08-15 20:15:57 +03:00
av 63a13404df заведена задача про сканер уязвимостей в зависимостях приложения 2026-08-15 19:02:36 +03:00
av 8c18abc24e закрыта задача spa-skeleton 2026-08-15 18:53:24 +03:00
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00
av c6ffda9aac web-ui.md: ссылка на закрытую задачу заменена ссылкой на спеку archive 2026-08-15 13:54:12 +03:00
av a5bc322814 закрыта задача json-api-for-spa 2026-08-15 13:52:06 +03:00
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00
av 79ff12548f tasks: спроектирован контракт приложения и переставлена голова очереди
- json-api-for-spa: адреса приложения уехали в своё пространство /app/,
  приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены
  список, карточка, текст, /app/me и /app/config; имя файла отправителя
  легло своей колонкой рядом с заголовком
- три действия над записью — правка заголовка, возврат в работу и журнал
  событий — собраны задачей audiorecord-actions
- голова очереди: контракт, каркас, экран загрузки, список, действия
- в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
2026-08-15 09:06:11 +03:00
av d88e56efcb claude.md: объявлена стадия стройки и выкладка с чистого листа
- на сервере данных нет и сервис остановлен, совместимость с ним не требуется
- запрет на боевой каталог и на переписанный шаг схемы этим не снимается
2026-08-15 08:12:18 +03:00
av b4b19db6e4 tasks: заведён урожай ревью remove-telegram-intake
- шесть записей по кластерам причин: журнал под внешним значением, нулевой
  код ответа в журнале, затирание вложения бедным ответом, открытый анониму
  адрес подтверждения почты, рубеж расшифровки без работы, пределы длительности
- находка про код 500 у отказа приёма дописана в json-api-for-spa: там живёт
  единая точка отображения доменной ошибки
- telegram-account-link и bot-api-only-through-bot-client оставлены с оговоркой,
  что предмета у них нет до возвращения входа
2026-08-15 07:46:16 +03:00
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00
av 97ceb7bb69 заведена задача failure-verdict-vs-retry 2026-08-14 20:38:12 +03:00
av 9a964f2efc закрыта задача record-centric-model 2026-08-14 20:20:58 +03:00
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00
av d079f03350 заведена задача на перестройку модели вокруг аудиозаписи
- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
2026-08-14 16:46:40 +03:00
av a67cdee382 закрыта задача record-ownership 2026-08-14 12:19:19 +03:00
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00
301 changed files with 41007 additions and 8724 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
# Раскладка av-dev в этом проекте: версия и настройки проверок.
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
version = 4 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
version = 5 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
[docs]
# каталог миграций: по нему docs.py сверяет схему с database.md
migrations = "internal/adapter/repo/pocketbase/migrations"
migrations = "internal/adapter/repo/sqlite/migrations"
[tasks]
# каталог задач от корня репозитория; имена частей — умолчания скрипта
+30
View File
@@ -0,0 +1,30 @@
# Что не уезжает в контекст сборки образа.
#
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
# Сборка при этом зелёная — расхождение молчаливое.
#
# `.gitignore` этого не закрывает: docker его не читает.
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
web/node_modules/
web/embed/dist/
web/*.tsbuildinfo
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
# у себя. В образе им делать нечего.
data/
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
config.toml
.env
# История репозитория: в слой сборки не нужна.
.git/
.gitignore
# Каталоги процесса, а не сборки.
openspec/
tasks/
docs/
+15 -8
View File
@@ -20,14 +20,9 @@ transcriber
# Go workspace file
go.work
# Database files
data/transcriber.db
data/transcriber.db-shm
data/transcriber.db-wal
# Uploaded files
data/files/*
!data/files/.gitkeep
# Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
# подкаталоги с файлами записей. Раскладку задаёт сервис.
data/
# IDE files
.vscode/
@@ -51,7 +46,19 @@ Thumbs.db
# Config files
config.toml
# Переменные окружения: сервис их не читает, настройки приезжают из TOML.
# Строка стоит против того, чтобы секрет завёлся здесь руками: из этого файла
# он попадает в git тем же способом, каким попал бы из конфига.
.env
# Sample and test audio files
*.m4a
*.mp3
*.ogg
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
web/node_modules/
web/embed/dist/
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
web/*.tsbuildinfo
+15 -4
View File
@@ -64,8 +64,8 @@ linters:
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
# одной правкой. Окружение читает только godotenv в main.go — он кладёт
# .env в окружение процесса, а не в настройки.
# одной правкой. Наш рабочий код окружение не читает вовсе — это
# правило и держит.
#
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
@@ -154,8 +154,19 @@ linters:
- (*os.File).Close
- (io.ReadCloser).Close
- os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
# Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
# а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
# закрытия нет.
- (*database/sql.Rows).Close
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
# отвечает на него «уже закончена», и проверка этого отказа означала бы
# разбор штатного исхода.
- (*database/sql.Tx).Rollback
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
# браузере наполняла бы журнал ничем.
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
exclusions:
rules:
+148 -53
View File
@@ -9,11 +9,13 @@
## Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
панель администратора.
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -24,24 +26,45 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
маршруты и слои на `net/http`, файлы записей своим каталогом,
`aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
его Biome и юнит-тесты Vue. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Проектирование
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
реализацию подставляет точка входа. Направления держат тесты-сканеры
`internal/archrules`, а не договорённость.
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
прикладной слой назначает порядок шагов, а не правила.
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
ним молча.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical**
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
пользователей хранилища — туда его кладёт приведение настроек при каждом
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
места запрета это не отменяет.
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища,и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -53,34 +76,71 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
остаток — [docs/security.md](docs/security.md).
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
**major**
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
переехала на карточку. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: хранилище считает применённое по имени файла.
файлом шага. Необратимо: учёт применённого ведёт сама база.
**critical**
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
*Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
поле теряется **только у задачи, попавшей к воркеру**. **major**
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
достался другому, завершается без записи и без ответа отправителю. Иначе два
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
- **Колонки записи правятся в трёх местах** пакета хранилища —
`writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
`rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
ноль поверх сохранённого.
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
возвращает идентификатор и признак своего захвата, и перечень перестал расти
с моделью. Отображение при этом идёт **по имени колонки**: именованные
параметры запроса и место назначения, найденное по имени, — позиционный список
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
`internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек вернул запись в
работу подкомандой оснастки, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
держит это схема, а не договорённость: колонка объявлена внешним ключом на
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major**
## Команды
@@ -91,11 +151,17 @@ go test ./... # в гейте идёт с -race, и там нуже
go vet ./...
gofmt -l .
golangci-lint run
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
становится docker — тот же, которым собирается образ.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
в [docs/conventions/config.md](docs/conventions/config.md) строками
@@ -127,7 +193,8 @@ task gate # весь набор проверок разом
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
любой отказ шага выходит с 201, а код шага печатает строкой
(«exit status 3»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
схемы,
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
@@ -143,7 +210,14 @@ task gate # весь набор проверок разом
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
дерево либо требуют сети, а pre-commit обязан быть быстрым.
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
гейт, который висит, хуже красного.
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
@@ -169,9 +243,16 @@ task gate # весь набор проверок разом
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что.
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый:
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
сравнения ошибок приведением типа. Закрыто задачей
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
@@ -182,19 +263,25 @@ task gate # весь набор проверок разом
## Запреты
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит
ни одного обращения, и работает он одним входом, по HTTP. Пустого
`bot_token` для этого мало и больше не значит ничего: включён вход или нет,
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true`
роняет старт. Выключенного входа
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
наружу при этом не ходят, так что годятся выдуманные непустые значения;
подробности строками в `config.example.toml`.
база (`data/transcriber.db`), и записи живых людей
(`data/records/<запись>/`). На стройке под ним пусто и сервис
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
его ронять и пересоздавать можно свободно.
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
`internal/adapter/recognizer/memory.go`. Подробности строками в
`config.example.toml`.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
@@ -216,6 +303,14 @@ task gate # весь набор проверок разом
## Работа
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
решение человека; боевой каталог данных остаётся под запретом; выкладку
по-прежнему запускает человек.
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
@@ -234,7 +329,7 @@ task gate # весь набор проверок разом
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский.
- Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
+38 -2
View File
@@ -1,3 +1,28 @@
# Front build stage
#
# Приложение собирается до бинарника: вшивание требует готового каталога.
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
#
# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы
# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA`
# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ
# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет,
# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает.
# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся
# ценой одной сборки, а не размером выкладки.
FROM docker.io/library/node:24 AS front-build
WORKDIR /web
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Build stage
FROM docker.io/library/golang:1.26-alpine AS build-env
@@ -16,8 +41,14 @@ RUN go mod download
# Copy source code
COPY . .
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
COPY --from=front-build /web/embed/dist ./web/embed/dist
# Build the application
RUN CGO_ENABLED=0 go build -o transcriber .
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
# ----------------
# Production stage
@@ -60,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
RUN chmod 755 /usr/bin/entrypoint
# Set user
USER transcriber
#
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
USER 1000:1000
EXPOSE 8080
+58 -37
View File
@@ -1,25 +1,24 @@
# Transcriber Service
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API.
Сервис расшифровки аудиозаписей. Вход один — HTTP API.
## Возможности
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
- Приём аудиофайлов через HTTP API
- Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
- Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
## Технологии
- **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin
- **Telegram**: go-telegram-bot-api
- **HTTP**: стандартная библиотека, `net/http`
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg
- **Хранилище, файлы и панель**: встроенная PocketBase
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
- **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
- **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
- **Файлы записей**: свой каталог, подкаталог на запись
- **Метрики**: prometheus/client_golang
## Установка и запуск
@@ -35,18 +34,30 @@
```
4. Запустите приложение:
```bash
go run . -c config.toml
go run ./cmd/transcriber -c config.toml
```
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`.
### Белый список Telegram
### Кого пускают
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»;
известные прорехи образца конфига, включая недостающий ключ белого списка,
[docs/conventions/config.md](docs/conventions/config.md).
Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
перечень доверенных адресов в секции `[auth]`. Подробности
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
открывают по адресу сервиса.
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
Заполненная секция имитации при выключенном предохранителе роняет
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
сам, никого не спросив, и в бою этот ключ стоит `false`.
## Деплой
@@ -62,13 +73,16 @@ inv pl -- transcriber
## HTTP API
Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
— готовность задачи, `GET /auth/login`, `GET /auth/callback` и
`POST /auth/logout` — вход через провайдера
([access](openspec/specs/access/spec.md)), `GET /metrics` — метрики Prometheus с
префиксом `transcriber_`, `GET /health` — проверка живости. Сверх них тем же
портом отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
записи, `GET /app/audiorecords` — страница своих записей,
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
ничего: всякий прочий путь получает разметку приложения.
Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
@@ -78,49 +92,56 @@ inv pl -- transcriber
## Состояния задач
Перечень состояний, переходы между ними и число воркеров —
[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
## Структура проекта
```
transcriber/
├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск
├── cmd/
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
├── internal/
│ ├── entity/ # Модели: задача, файл, результат распознавания
│ ├── entity/ # Модели: запись, файл, результат распознавания
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
│ ├── config/ # Разбор config.toml
│ ├── metrics/ # Метрики Prometheus
│ ├── service/ # Конвейер расшифровки
│ ├── controller/
│ │ ├── http/ # HTTP-обработчики
│ │ ├── tg/ # Telegram-бот
│ │ └── worker/ # Фоновые воркеры
│ └── adapter/
│ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
── telegram/ # Отправка сообщений
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
└── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически)
── storage/ # Файлы записей в раскладке хранилища
├── transcriber.db # База (создаётся автоматически)
── migrate.lock # Замок наката схемы
└── records/ # Файлы записей: подкаталог на запись
```
## Хранилище
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
идентификаторов, а также механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
в журнал при первом запуске.
Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и
идентификаторов, раскладка файлов записи и механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное
его действие вне экранов — возврат остановленной записи в работу подкомандой
оснастки.
```bash
go run ./cmd/devtools resume -c config.toml <идентификатор записи>
```
## Разработка
Схему двигают шаги миграций PocketBase на Go
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
Применённый шаг не переписывается: изменение — только новым файлом шага.
Схему двигают шаги `pressly/goose/v3`
`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой:
+76
View File
@@ -24,6 +24,9 @@ tasks:
gate:
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
cmds:
# Приложение собирается первым: вшивание требует готового каталога, и
# `go build` без него соберёт бинарник со вчерашней сборкой.
- task: front
- go build ./...
- go vet ./...
- |
@@ -122,6 +125,79 @@ tasks:
fi
done
front:
desc: 'Приложение: зависимости, проверки, сборка'
vars:
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
# сверки у него, в отличие от версий Go, нет.
NODE_IMAGE:
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
cmds:
# Node на машину не ставится — он зовётся контейнером, тем же образом,
# каким собирается ступень образа. Требованием к машине остаётся docker.
- |
set -eu
if ! command -v docker >/dev/null 2>&1; then
echo "docker не найден в PATH"
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
exit 3
fi
if [ -z "{{.NODE_IMAGE}}" ]; then
echo "в Dockerfile не нашлось ступени с образом node"
exit 3
fi
mkdir -p "{{.NPM_CACHE}}"
run() {
docker run --rm \
-u "$(id -u):$(id -g)" \
-e npm_config_cache=/npmcache \
-v "{{.NPM_CACHE}}:/npmcache" \
-v "$PWD/web:/web" \
-w /web \
"{{.NODE_IMAGE}}" \
sh -c "$1"
}
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
# расходилось бы с собранным в образе.
#
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
# не требует вовсе.
if ! run 'npm ci --offline' >/dev/null 2>&1; then
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
# коротким обращением. Без этой проверки установщик уходит в повторы с
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
# хуже красного — он не даёт ни исхода, ни причины.
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
echo "реестр пакетов недоступен"
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
exit 3
fi
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
# отказ установки это отказ проекта: замок разошёлся с package.json,
# пакет снят из реестра, сломался его postinstall. По словарю кодов
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
# и сеть вместо `git diff web/package-lock.json`.
if ! run 'npm ci'; then
echo "зависимости приложения не установились, а реестр доступен"
echo "смотри расхождение web/package-lock.json с web/package.json"
exit 1
fi
fi
run 'npm run check && npm run test && npm run build'
shell:
desc: 'shellcheck на скрипты оболочки'
cmds:
+53
View File
@@ -0,0 +1,53 @@
// Command devtools — оснастка разработчика: то, что нужно для локального
// прогона и никогда не едет в боевой образ.
//
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
// на место панели владельца: панели у сервиса больше нет, а экраны правки
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
// входа локального прогона подставляет сам сервис по своим настройкам.
//
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
//
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
package main
import (
"fmt"
"log"
"os"
)
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "resume":
runResume(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
resume вернуть остановленную запись в работу
`)
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"flag"
"fmt"
"log"
"os"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// stepResume — чем возврат в работу подписывается в журнале событий записи.
const stepResume = "resume"
// runResume возвращает остановленную запись в работу.
//
// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а
// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал
// панелью, отложить до экранов нельзя было одно — возврат остановленной записи.
//
// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан
// сбросить — признак остановки, признак захвата и срок его протухания, число
// отказов, паузу и время входа в рубеж, — исполняет домен одним действием.
// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата,
// либо останавливаемой снова первым же захватом — молча, без единой строки.
//
// Событие журнала записи пишется с происхождением «человек»: иначе запись,
// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от
// записи, которую конвейер вёл без остановок, а происхождение события перестаёт
// различать что-либо.
func runResume(args []string) {
flags := flag.NewFlagSet("resume", flag.ExitOnError)
configPath := flags.String("c", "config.toml", "путь к файлу настроек")
if err := flags.Parse(args); err != nil {
os.Exit(2)
}
if flags.NArg() != 1 {
fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] <id>\n")
os.Exit(2)
}
recordID, ok := ident.Parse(flags.Arg(0))
if !ok {
log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0))
}
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("настройки не читаются: %v", err)
}
if err := cfg.Storage.Validate(); err != nil {
log.Fatalf("настройки хранилища негодны: %v", err)
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
log.Fatalf("база не открывается: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
log.Printf("база закрылась с отказом: %v", err)
}
}()
records := sqliterepo.NewAudioRecordRepository(db)
events := sqliterepo.NewRecordEventRepository(db)
record, err := records.Get(recordID)
if err != nil {
log.Fatalf("запись не читается: %v", err)
}
if !record.IsHalted() {
log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID)
}
// Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя
// у остановленной записи нет, и сверять признак захвата не с чем.
record.Resume()
if err := records.Save(record, ""); err != nil {
log.Fatalf("запись не сохраняется: %v", err)
}
if err := events.Append(&entity.RecordEvent{
RecordID: recordID,
Origin: entity.EventOriginHuman,
Step: stepResume,
Outcome: entity.EventOutcomeResumed,
}); err != nil {
log.Fatalf("событие журнала записи не сохраняется: %v", err)
}
log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State)
}
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
"git.vakhrushev.me/av/transcriber/web"
)
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
// кончается ненулевым кодом выхода.
//
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
// распознавания.
func main() {
// Создаем структурированный логгер
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
if err := run(logger); err != nil {
logger.Error("Transcriber service failed to start", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
// Parse command line flags
configPath := flag.String("c", "config.toml", "Path to config file")
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
flag.Parse()
cfg, err := config.LoadConfig(*configPath)
if err != nil {
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
}
logger.Info("Configuration loaded successfully", "config_path", *configPath)
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
return err
}
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil {
return err
}
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
// заголовка, которого сервис не читает, и имитация без годного логина роняют
// старт. Имена заголовков приходят проверке доводом — дом у них один,
// константы транспорта, — а пакет настроек транспорта не знает.
if err := cfg.ValidateTestHeaders(
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
); err != nil {
return err
}
// Представление предиката «подставляем ли» одно — непустота перечня, — и
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
// Второе выражение того же предиката разошлось бы с первым молча.
substitution := cfg.HeaderSubstitution()
if len(substitution) > 0 {
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
// спросив, — ровно то «может стать проблемой», ради которого заведён
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
// чужому архиву.
logger.Warn("Identity headers are substituted from configuration",
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
"capability", "access")
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
if err := cfg.Pipeline.Validate(); err != nil {
return err
}
if err := cfg.Storage.Validate(); err != nil {
return err
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
return err
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("Failed to close the database", "error", err)
}
}()
// Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
// причине их становятся сотни.
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
return err
}
store := sqliterepo.NewStore(cfg.Storage.DataDir)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
repos := service.Repositories{
Records: recordRepo,
Files: fileRepo,
Texts: sqliterepo.NewTextRepository(db),
Structures: sqliterepo.NewStructureRepository(db),
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: sqliterepo.NewRecordEventRepository(db),
}
users := sqliterepo.NewUserRepository(db)
// Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey,
SecretKey: cfg.Yandex.ObjStorageSecretKey,
BucketName: cfg.Yandex.ObjStorageBucketName,
Endpoint: cfg.Yandex.ObjStorageEndpoint,
ApiKey: cfg.Yandex.SpeechKitAPIKey,
FolderID: cfg.Yandex.FolderID,
})
if err != nil {
return fmt.Errorf("failed to create audio recognizer: %w", err)
}
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
// недоступности Yandex.
defer func() {
if err := recognizer.Close(); err != nil {
logger.Error("failed to close audio recognizer", "error", err)
}
}()
transcribeService := service.NewTranscribeService(
repos,
metaviewer,
converter,
recognizer,
cfg.Pipeline.StuckLimits(),
logger,
)
// Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
// самой записи. Число приходит настройкой, ноль — законное значение.
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
wg.Add(1)
go func() {
defer wg.Done()
pool.Start(ctx)
}()
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
appHandler := httpcontroller.NewAppHandler(
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
)
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения, правило неизвестного пути у раздачи приложения и область
// действия узнавания.
mounts := httpcontroller.ServiceMounts(
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
promhttp.Handler(),
)
dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
// модели угроз проекта.
ReadTimeout: 0,
}
serveErr := make(chan error, 1)
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
}
}()
// Настраиваем обработку сигналов для graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера
var startupErr error
select {
case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...")
case err := <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
startupErr = err
}
// Останавливаем HTTP сервер
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel()
logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server forced to shutdown", "error", err)
} else {
logger.Info("HTTP server stopped gracefully")
}
// Отменяем контекст для остановки воркеров
cancel()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logger.Info("All workers stopped gracefully")
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
logger.Warn("Timeout reached, forcing shutdown")
}
logger.Info("Transcriber service stopped")
return startupErr
}
+118 -62
View File
@@ -4,11 +4,74 @@ port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Storage configuration
# Единственный каталог данных: под ним лежат и база, и файлы записей.
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
#
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
# распознавателя признак не касается.
#
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
debug = false
# Хранилище: каталог данных и числа его базы.
#
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
# записей. Двух путей у хранилища не бывает.
[storage]
data_dir = "data"
# Сколько ждать занятую базу, миллисекунды. Положительное число.
#
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
# сразу» и потому не принимается.
busy_timeout_ms = 5000
# Сколько соединений держит читающий пул. Положительное число.
#
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
# Число выводят из числа воркеров плюс запас под запросы приложения.
#
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
# означало бы отказы по занятости на записи результата шага, то есть после
# оплаченной работы.
read_connections = 4
# Конвейер расшифровки.
[pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
# работе запись и выбирает шаг по её рубежу.
#
# Ноль — законное значение, а не поломка: сервис поднимается, записи
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
# надо остановить, не роняя приём.
workers = 3
# Предел простоя записи там, где работу делаем мы сами, в минутах.
#
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
# многочасовая запись занимает на приведении, и это принято сознательно
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
# владельца, потому что остановка обратима и рубежа не стирает.
own_work_limit_minutes = 60
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
#
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
# вечно.
foreign_work_limit_minutes = 1440
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
@@ -33,66 +96,59 @@ object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
# Кому сервис верит на входе.
#
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
# никому», то есть сервис, поднявшийся никого не узнающим.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
# бот молча пропадает, либо файл без признака молча работает.
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
# шлёт запрос.
#
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
# выбор владельца, а не отклонение.
#
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
# записью журнала, потому что основной вход у него другой.
#
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
# не работает: её подменяют в коде.
enabled = false
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
# Caddy, — узкий и свой.
trusted_proxies = ["172.20.0.0/24"]
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
# включением входа он больше не заведует, этим занят enabled выше. При
# enabled = false не читается вовсе.
bot_token = ""
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
# Локальный вход без Authelia — рецепт целиком.
#
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
# сервис: второго процесса и второго порта для этого не нужно, приложение
# открывают по адресу сервиса.
#
# Три правки этого файла сверху вниз, и других не нужно:
#
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
#
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
#
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
# журнала с адресом пира — по ней и опознаётся недостающая запись.
#
# 2. Поставить в секции [server] выше:
#
# debug = true
#
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
# узнает никого.
#
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
# учётной записи.
#
# Заполненная секция при debug = false роняет старт с именем ключа
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
# никак, а обе его прочтения — поломка.
#
# [auth.test_headers]
# Remote-User = "local"
# Remote-Name = "Разработчик"
# Remote-Email = "local@example.com"
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
разведки `job-queue-choice`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
@@ -5,6 +5,7 @@
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -5,6 +5,7 @@
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
@@ -0,0 +1,63 @@
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
- **Дата:** 2026-08-14
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
## Решение
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
никак: ни владельцем панели, ни самим человеком.
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
предложенных способов.
Дословно из источника:
> **Что делать с записями удалённого пользователя?** Связь при выключенном
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
> признать потерю ценой и записать её.
## Почему
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
значения не остаётся нигде.
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
было неверным, и это выяснилось на ревью дизайна.
## Чем платим
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
## Что рассматривалось и отвергнуто
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
строка, и целостность, ради которой выбрана связь, теряется.
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
## Связанное
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
удаляется».
@@ -0,0 +1,51 @@
# Остановка записи — признак, а не рубеж
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Остановка — признак, а не рубеж»
## Решение
Прежние состояния отказа и смерти (`failed`, `dead`) схлопнуты в **признак
остановки** с причиной: `halted_at`, `halt_reason`, `error_text`. Достигнутый
рубеж при остановке не стирается, и снятие признака продолжает работу с того
места, где запись встала.
## Почему
Цитата источника:
> **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
> остановки, массовый перезапуск после выкатки правки делается одним
> обновлением, а различие «мы рассудили» против «мы перестали пробовать»
> остаётся причиной, которую человек читает.
Отвергнуты два варианта, оба с названной ценой:
> **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
> закрыт схемой, и каждая новая причина стоила бы необратимого шага.
>
> **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
> руками в панели остаётся единственным способом вернуть запись в работу, и
> делается он наугад.
Прежняя модель описана решением
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md):
там состояние «мертва» заводилось взамен признака `is_error`, и довод был тот
же — «два способа вывести задачу из выборки расходятся». Довод устоял, а
носитель сменился: теперь единственный способ вывести запись из выборки — этот
признак, и состояние его больше не дублирует.
## Последствия
- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого
рубежа, а не начинает конвейер заново.
- `+` новая причина остановки стоит значения в закрытом перечне причин, а не
нового состояния и не нового шага схемы.
- `+` массовый возврат в работу после выкатки правки делается одним обновлением
колонки.
- `` в выборке захвата появилось четвёртое условие, и рубеж перестал быть
единственным, что выводит запись из работы: читать состояние записи теперь
надо двумя полями.
- `` перечень причин закрыт схемой, то есть новая причина всё же требует шага
схемы — дешевле прежнего, но не бесплатно.
@@ -0,0 +1,47 @@
# Ответ распознавателя хранится дословно, двоичной формой и вложением
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сырой ответ провайдера хранится вложением, а не колонкой»
## Решение
Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей
двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток
распознавания, а не колонкой.
## Почему
Цитата источника:
> Хранится он вообще потому, что **результат операции у провайдера не
> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и
> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит
> денег за каждую запись.
Вложением, а не колонкой:
> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а
> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой,
> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в
> перечне колонок захвата сегодня.
Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер:
текстовое представление собирается по нашей скомпилированной схеме и **молча
выбрасывает поля, которых в ней нет**, а провайдер добавляет их без
предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись
и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился.
## Последствия
- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с
говорящим станет доступна, когда мы научимся её читать.
- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память.
- `` **формат файла на диске объявлен необратимым**: сохранённое не читается
глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без
сервиса нельзя вовсе.
- `` каталог данных растёт быстрее прежнего: ответ многословнее самой
расшифровки — несёт альтернативы, время каждого слова и разбор говорящих.
Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на
деле у шестичасовой записи, не мерил никто.
@@ -0,0 +1,45 @@
# Предел простоя остаётся часом, хотя он короче самой работы
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сторожей двое, и предела времени — два числа»
## Решение
Сторож застревания ограничивает время записи в рубеже двумя числами: **час** на
свою работу, **сутки** на ожидание чужой операции. Час меньше времени, которое
многочасовая запись занимает на приведении, и это принято сознательно.
## Почему
Ревью дизайна показало, что число противоречит расчётному потолку записи:
> Расчётный потолок записи — шесть часов, приведение такой записи идёт дольше
> часа по построению, а срок захвата шага приведения стоит сегодня восемью
> часами. Значит длинная запись, отказавшая один раз и ждущая повтора дольше
> часа, будет остановлена сторожем застревания вместо расшифровки.
Предложено было вывести предел из срока захвата — двенадцать часов на свою
работу. Владелец решил оставить час, и довод записан цитатой:
> Оставляем час. Тут нужно принять, что это скорее про зависшую задачу, потому
> что пока идёт обработка даже длинной записи мы всегда можем проверить, жив ли
> процесс конвертера.
Довод держится на том, что остановка теперь **обратима**: она не стирает рубежа,
и снятие признака возвращает запись туда, где она стояла (см.
[ADR-2026-08-14-halt-is-a-flag-not-a-stage](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md)).
Цена ложной остановки поэтому равна одному движению владельца, а не потерянной
записи.
## Последствия
- `+` зависшая запись обнаруживается за час, а не за восемь.
- `+` число живёт в настройках и правится без шага схемы, если класс начнёт
всплывать.
- `` длинная запись, отказавшая один раз и прождавшая повтора дольше часа,
останавливается как застрявшая — владельцу приходится снимать признак руками.
- `` предел этот работает только по записи, вернувшейся в выборку. У держателя,
погибшего жёстко, запись невидима сторожу до истечения **срока захвата** её
рубежа, то есть восьми часов у приведения; замер и оговорка стоят строкой в
`database.md`.
+39
View File
@@ -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`. Оплачено
стадией — на сервере данных нет, внешней программы на прежнем контракте не
существует.
+33
View File
@@ -0,0 +1,33 @@
# Страница архива задаётся ключом, а не номером
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Страница задаётся ключом, а не номером»
## Решение
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
заведения и идентификатор». Номер страницы отвергнут.
## Почему
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
котором записи нет.»
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
между ними одним лишь временем не определён.
## Последствия
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
пропуска;
- `+` порядок между записями с равным временем устойчив;
- `` экран с нумерацией страниц так не сделать — листать можно только
«дальше». Архиву это не нужно;
- `` ключ приходит от клиента и потому разбирается: время приводится к виду
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
истину или ложь.
@@ -0,0 +1,62 @@
# Node зовётся контейнером, а не ставится на машину разработчика
- **Дата:** 2026-08-15
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
раздел «Node не ставится на машину, а зовётся контейнером»
## Решение
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
а не вызывает их из `PATH`:
> Требованием к машине разработчика становится docker, которым и так собирается
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
> вовсе.
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
вторым числом в `Taskfile.yml`.
## Почему
Довод в дизайне назван прямо:
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
> машине разработчика и версия в образе — два разных числа, и собранное ими
> приложение различается ровно тогда, когда различаются они.
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
стоит.
## Почему это ADR
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
`npm run build`».
## Что это меняет в прежнем решении
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
записи нет: заменена не она, а толкование одного её последствия.
## Последствия
- `+` версия сборочного окружения живёт **одним** местом — ступенью
`Dockerfile`, — и своего шага сверки ей не нужно.
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
совпадает окружение сборки, а не потому что «обычно совпадает».
- `` **набор проверок перестаёт работать без docker**, и отказ этот приходит
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
шагов в наборе становится два вместо одного.
- `` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
установщика уводится наружу — обе частности обязательны: без них собранное
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
- `` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
не растёт (ступень в рабочий слой не копируется), а время сборки решением
владельца от 2026-08-15 не замеряется вовсе.
@@ -0,0 +1,59 @@
# Обязательность владельца держит схема, а не приём
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Схема теряет только необязательность владельца» и «Владелец записи
перестаёт быть необязательным и в модели»
## Решение
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
вместо ссылки, которой позволено отсутствовать.
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
строки надо запросом до выкладки.
## Почему
Цитата источника:
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
допускать пустое значение… Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
них владельца не было по построению. Вход убран
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
владельца в боевой базе нет.
Про непроверку существующих строк цитата источника:
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
> оракулом: хранилище держит обязательность связи проверкой записи при
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
> первым шагом плана перехода.
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
**спрашивать** ничьим именем.
## Последствия
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
модели, ни в отборе.
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
и приём, — одним, а не двумя.
- `` откат шага возвращает необязательность, но операционно недостижим: команд
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
- `` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
событий, только строка в логе контейнера.
@@ -0,0 +1,37 @@
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Три новых колонки записи и один шаг схемы»
## Решение
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
уже есть у строки её файла. Равенство между ними не поддерживается никем —
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
## Почему
Обе величины показываются в списке, а список по норме `storage` читается без
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
держать равными. Решением владельца колонки остались, а равенство объявлено
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
файле — величины той копии, которой файл является сейчас». Уточнение
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
«что лежит сейчас».
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
метаданными отвергается отказом и не заводится.
## Последствия
- `+` страница списка не читает по строке файла на каждую запись;
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
- `` в применённом шаге схемы навсегда остаются две колонки, повторяющие
величины строки файла; расхождение между ними — не поломка, и заметить его
нечем;
- `` запись, заведённая рукой в панели без величин, покажет человеку ноль.
@@ -0,0 +1,40 @@
# Метка убранного входа не выставляется вовсе, а не обнуляется
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
раздел «Метка убранного входа не выставляется вовсе»
## Решение
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
меткой `telegram` не появляется после выкладки.
## Почему
Цитата источника:
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
> месте.
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
означает именно неподнятый вход, а не отсутствующий.
С единственным оставшимся входом проверяемым осталось только **множество меток**:
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
когда входов у сервиса станет больше одного.
## Последствия
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
авария.
- `` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
Владельцу, если такой отбор был заведён, править его руками.
- `` требование «различать поднятый и неподнятый» стало непроверяемым до
возвращения второго входа, и это сказано в самом требовании прямо.
@@ -0,0 +1,63 @@
# Вход Telegram убран целиком, а не выключен признаком
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Context» и «Формы решения, между которыми выбирали»
## Решение
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
связью чата с учётной записью.
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
значение `telegram` перечня источников остаются в схеме вместе с записями,
которые их заполнили.
## Почему
Цитата источника:
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
> записи заводятся, правило «каждая запись принадлежит человеку» действует
> наполовину.
Отвергнуты две формы решения, обе с названной ценой:
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
> следующая задача.
>
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
> понятие «канал уведомления владельца», которое тут же переделает задача
> `ntfy-delivery`.
Решениями, которые это изменение отменяет, были
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
и
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
чей внешний собеседник недоступен».
## Последствия
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
доступа, конвейера и хранилища.
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
утечки токена, которые проект закрывал двумя задачами.
- `` у сервиса не осталось входа, которым человек может воспользоваться:
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
собранным руками запросом с сессией из браузера. Владелец окно принял.
- `` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
- `` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
Отправитель голосового не получит ни ответа, ни отказа.
@@ -0,0 +1,73 @@
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
- **Дата:** 2026-08-22
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
## Решение
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
самого соединения. Учётная запись заводится первым обращением с новым логином и
находится по нему же дальше.
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
сессии.
## Почему
Цитата из источника, раздел Р3:
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
> заголовку, который прокси поставил, сходив к Authelia.
>
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
> значение, живущее семь суток, отозванный у провайдера человек работал до
> истечения этого значения, и другого канала отзыва не было.
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
заголовком:
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
> барьер вовсе.
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
продолжение (Р3):
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
> суток вернулись бы вместе с ним.
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
соседним сервисам того же контура, а правила для этого сервиса там не было
вовсе — он не выложен.
## Последствия
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
Authelia судит каждое обращение.
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
секрета.
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
накопившихся задач о его механике.
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
знака перестаёт существовать.
- `` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
проверить отсюда нечем: правило живёт в `pet-project-server`.
- `` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. Неизменяемого признака заголовок не приносит; не допускать
переиспользования — работа провайдера. Обратная сторона: переименование
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
убрать.
- `` Поиск учётной записи идёт на каждом запросе к области приложения вместо
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
требовалось — сервисом пользуются единицы человек.
- `` Половина работы лежит вне репозитория: до того как правило прокси и правило
Authelia на домен заведут, сервис не узнает никого.
@@ -0,0 +1,98 @@
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
- **Дата:** 2026-08-22
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
записка разведки о выборе хранилища
## Решение
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе.
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
названо здесь.
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
выполняет сама.
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
делает запрос к базе руками» — читается этой сменой.
Доводы обоих решений записаны в
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
ничем, а возврат в работу делает подкоманда оснастки».
Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
очереди по-прежнему не берём: замер, которым это решено, снят на
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
Приложение остаётся в своём корне `/app/`
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
полем коллекции и коротким токеном.
## Почему
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
этом коде. Цитата из источника:
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
> шесть ролей сразу, и только две из них про хранение.
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
> которым отвергнут отвергнутый вариант, перестал быть верным.
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
вторая порция работы стоит дороже, чем то, что она сберегает.
Обстоятельство, которое назначило момент:
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
> кодом на чужих типах с каждой задачей.
## Последствия
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
дефектом `/%5f/` из [../security.md](../security.md), а пространство
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
панели.
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
удаление записи и вторая копия рядом становятся обычной работой с файлами.
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
- `` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
улучшением, а заменой утраченного инструмента.
- `` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
с этого дня.
- `` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
нулевой.
- `` 1497 строк контроллера и 3075 строк его проверок написаны на
`*core.RequestEvent` и переписываются целиком.
@@ -0,0 +1,76 @@
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решение 4
## Решение
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
в источнике дословно:
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
круга тех, кто мог назваться кем угодно, не расширяет».
## Почему
Решение покупает работоспособность отладочного входа там, где адрес пира не
петлевой:
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
> переставляет перечень доверенных адресов на петлевой: петлевые записи
> добавляются к тем, что в нём уже стоят.
Цена названа в источнике прямо, и владелец принял именно её:
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
> пришедший обычным путём**.
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
копируют с машины разработчика.
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
предохранителя, которого решением владельца нет».
## Почему это ADR
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
при включённом отладочном входе — первое, что предлагает всякий, кто читает
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
эту дыру находит заново.
## Последствия
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
нему добавляются.
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
проверки.
- `` **Машина не исключает боевую поломку «конфиг с `debug = true` и
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
угроз, [security.md](../security.md), «Периметр», и в спеке
[access](../../openspec/specs/access/spec.md).
- `` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
правило прокси про заголовки `Remote-*`.
@@ -0,0 +1,86 @@
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решения 1, 6, 7 и 10
## Решение
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
цепочки корня приложения, а не вспомогательным процессом рядом:
> отдельный слой цепочки корня приложения, стоящий **перед**
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
> выдаёт, в контекст не пишет.
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
> входить локально не остаётся тоже.
Имена заголовков служат именами ключей секции, но набор принимаемых имён
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
ни с одним из них, роняет старт.
## Почему
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Дизайн записал это целью:
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
> настроек, и ничем больше.
Довод в пользу слоя перед узнаванием, а не внутри него:
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
подстановку из боевой сборки тегом сборки:**
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
## Почему это ADR
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
«почему подстановка вообще есть в боевом бинарнике».
## Последствия
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
открывают по адресу сервиса, второго порта нет.
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
пришедшего от прокси к моменту, когда его читают.
- `+` Второго способа входить локально не остаётся, и документация перестаёт
каждый раз говорить, какой из способов чей.
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
- `` **Местный инструмент больше не воспроизводит поломки контура.** Цена
названа в источнике прямо: два значения `Remote-User`, заголовок с
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
воспроизводит только автотест, ставящий заголовок сам.
- `` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
- `` У ключа `[server] debug` закрытый перечень следствий, и держать его
придётся руками: новое поведение привязывается к ключу только отдельным
решением владельца и получает своё требование спеки
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
следствий обрастает ими молча.
- `` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
выключена — но лишние записи копятся.
+22 -7
View File
@@ -35,12 +35,27 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
| 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) | |
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
@@ -49,8 +64,8 @@
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
+284 -117
View File
@@ -15,168 +15,297 @@
[conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
входов**: приём только от узнанного, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
выключенный вход Telegram не мешает подъёму. Задачи
наблюдатель видит единственный поднятый вход. Задачи
`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. Приём из Telegram по существу —
кто допущен и как забирается запись — здесь по-прежнему не описан;
`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. Переходы состояний и отмена
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
`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;
и её файл, как файл отдаётся и что видит владелец: задачи `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, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
видит всё, что видел прежде аноним.
его дальше: узнавание по заголовку доверенного источника, заведение учётной
записи первым обращением и то, какие адреса остаются открытыми. Собственный
вход через 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).
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability.
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
Задача, которая его трогает, дописывает спеку своей capability.
## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
- **Один процесс.** 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).
кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
одно слово — таблица перестала быть коллекцией.
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
задача возвращается в работу, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция `[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`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов.
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
роутер этого хранилища, а не наш сервер поверх него. Правила на это
направление нет намеренно.
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
разрешавшее транспорту знать адаптер хранилища, снято 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; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает |
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| 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 |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
| Распознаватель | `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` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
делает задача, исчерпавшая попытки, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»».
Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
`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.enabled` едет в конфиг раньше образа.** Он
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
правка конфига безопасна, а поздняя роняет сервис.
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
новее. На более старом состояния «сервис поднят, бот опущен» не существует
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
поделят один длинный опрос и часть ответов до людей не дойдёт.
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
разное для двух ключей секции 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 -->
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
воркеры опрашивают базу вхолостую с паузой из
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Приём аудио и заведение записи | `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.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Переход записи на рубеж | `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` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
## Деплой
@@ -185,40 +314,73 @@
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`.
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
процесс работает под непривилегированным пользователем `transcriber`.
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
собирается первым — вшивание требует готового каталога, — а в рабочий слой 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.
## Открытые вопросы
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
[access](../openspec/specs/access/spec.md), решения
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
обратный прокси, сходив к 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
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
обходится слой 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).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
`deferred-general` по длине не выяснены. Расчётные
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала».
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
она осталась только этим числом.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново.
@@ -233,26 +395,31 @@
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
или нет, тоже не решено.
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
([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). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.
+64 -32
View File
@@ -5,20 +5,22 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
ключи `[yandex]` ловит конструктор распознавателя.
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
его в окружение процесса, а не в настройки приложения.
приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.
## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`.
@@ -46,7 +48,6 @@
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
@@ -58,13 +59,26 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
`client_secret` — он и есть секрет.
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
применённая порознь любая из них роняет старт либо оставляет сервис никого не
узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.
*Расхождение:* петлевые адреса в рецепте названы **парой**`127.0.0.1` и
`::1`, — а не одним значением, хотя правило секции требует от образца только
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
входа.
## Поля по дискриминатору `type`
@@ -91,9 +105,10 @@ Ansible из `pet-project-server`). Приложение просто читае
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
2026-08-22 вместе с собственным входом.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.example.toml` секретные поля — пустые строки.
@@ -114,6 +129,16 @@ Ansible из `pet-project-server`). Приложение просто читае
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
платить не за что.
**Файл, способный нести секрет, называется в `.gitignore` и в
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
бы проверкой над проверкой, а такие проект не заводит
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
строку с одной стороны — вернуло её ревью.
## Проверка и остановка на старте
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
@@ -130,23 +155,29 @@ Ansible из `pet-project-server`). Приложение просто читае
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет.
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true`
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
включения решает, поднимается ли вход Telegram».
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
`client_secret` в журнал попасть не должно.
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
HTTP.
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
силе для прочих секций, где секреты есть.
## Структура в коде
@@ -165,5 +196,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первое такое
поле `telegram.enabled`.
`config.example.toml` у поля стоит значение свежей установки. Первым таким
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+29 -30
View File
@@ -2,11 +2,11 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
держит линтер. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
записи не осталось.
**Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
@@ -17,17 +17,17 @@
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
**приложением** в момент создания записи. Выдача монотонна внутри одной
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
- **Точка генерации и разбора одна**`internal/ident`: `New` выдаёт, `Parse`
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
заводим.
## Канонический вид — lowercase
@@ -47,29 +47,28 @@
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
панель владельца, и она вправе была завести значение, которого сервис не
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
покупателя.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
разошедшийся вид молча обращает условие срока захвата в константу.
- Миграции — шаги PocketBase на Go
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
срока захвата в константу.
- Миграции — шаги `pressly/goose/v3` на Go
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
структуры обновляем схему [../database.md](../database.md) тем же изменением.
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+55 -27
View File
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
отправителя не знает ни задачи, ни чата, и нести ей нечего.
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
как случай, а не как живой код.
## Граница и трансляция: приватный и публичный канал
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
API). Сюда отдаём:
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
@@ -99,29 +98,51 @@ transcriber — **приложение, а не библиотека**: внеш
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | Сообщение |
| --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
| Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | --- |
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим 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.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
@@ -131,9 +152,9 @@ transcriber — **приложение, а не библиотека**: внеш
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
пользователь Telegram видит отдельный человекочитаемый текст — это часть
правила соблюдена.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
машинного текста — эту часть правила держит спека `archive`.
## panic
@@ -142,13 +163,20 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
паника в шаге конвейера роняет процесс целиком.
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
возвращается на первом несовпадении. Заметило ревью задачи
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
впервые, чинит одну ошибку за прогон.
+16 -12
View File
@@ -78,10 +78,11 @@
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
@@ -89,9 +90,11 @@
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемызапись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
@@ -116,7 +119,7 @@
| --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go
@@ -129,12 +132,15 @@
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
### Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
@@ -151,7 +157,7 @@
| Подавлено | Где | Почему |
| --- | --- | --- |
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
@@ -184,11 +190,9 @@
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
есть роутер этого хранилища. Изъятие названо в
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
нет.
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../architecture.md](../architecture.md), «Слои и модель домена».
Отдельно названы **правила, чей подъём отклонён**:
+48 -48
View File
@@ -28,22 +28,28 @@ OpenSpec.
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
*Расхождение:* текстовый обработчик ставит `cmd/transcriber`
`slog.NewTextHandler(os.Stdout, …)`.
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
выглядел бы хуже, чем есть.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
- `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
`log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
@@ -60,7 +66,7 @@ OpenSpec.
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила:
@@ -78,9 +84,14 @@ OpenSpec.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
идёт на `INFO` — уровень и его довод нормирует спека
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
журнале».
## Время
- Поле — `time` (ключ `slog` по умолчанию).
@@ -94,19 +105,22 @@ OpenSpec.
- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`.
`ext.*`, `webapp.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
@@ -120,16 +134,16 @@ OpenSpec.
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("job_id", job.Id, "capability", "conversion")
log := log.With("record_id", record.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`.
`jq 'select(.record_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
@@ -137,7 +151,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
- приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
@@ -169,9 +183,9 @@ log := log.With("job_id", job.Id, "capability", "conversion")
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
- `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
@@ -191,13 +205,12 @@ log := log.With("job_id", job.Id, "capability", "conversion")
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
Object Storage и опрос операции не логируются никак.
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
@@ -206,19 +219,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой,
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Расхождения здесь больше нет: слой журналирования запросов свой
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
@@ -234,34 +245,23 @@ Object Storage, скачивание файла из Telegram и опрос оп
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
`sendMessage`, скачивание записи и `getMe` из конструктора;
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
замена точная — токен известен.
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
отказа и строке журнала.
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
неверной.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
@@ -277,5 +277,5 @@ Bot API, поэтому чистка на месте употребления з
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+49 -16
View File
@@ -10,17 +10,19 @@
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
целиком вместе со сменой решения на SPA 2026-08-10.
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на
пробном экране, а не из написанного кода: первым их применяет и проверяет
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, —
повод править эту запись, а не обходить её молча.
Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
они были выведены из выбора и из замера на пробном экране, а не из написанного
кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
эту запись, а не обходить её молча.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md).
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения
пока нет.
команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
требованием к машине разработчика остаётся docker, а не установленный Node.
## Что решено про само приложение
@@ -51,7 +53,14 @@
делать то же самое.
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
Vite, руками их не задаём: от этого зависит обновление установленного
приложения.
приложения. Сервис на это правило опирается, но проверить его не может — имён
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
отпечатка в имени, останется в хранилище браузера навсегда.
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
совпадать с собранным в образе.
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
роняет гейт наравне с `go build`.
@@ -61,11 +70,26 @@
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
установке и на нашем числе маршрутов не окупается
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
экранов и какие — не здесь: состав нормирует спека приложения, а до неё его
держит [spa-skeleton](../../tasks/items/spa-skeleton.md).
экранов и какие — не здесь: состав нормирует спека
[webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
пути внутри `/api/` в приложение не проваливаются никогда.
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
ушёл с собственным входом, два других — со встроенным хранилищем и его
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
смены хранилища не изменились ни одним полем. Перечень корней сервису не
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
ответов сервиса не останется ничего.
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
посередине.
@@ -83,15 +107,17 @@
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`.
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
— [access](../../openspec/specs/access/spec.md).
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
[access](../../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
## Показ ошибок и состояний
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
([json-api-for-spa](../../tasks/items/json-api-for-spa.md)), и второй словарь
(спека [archive](../../openspec/specs/archive/spec.md)), и второй словарь
на клиенте разошёлся бы с первым.
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
строкой «связи нет», а не пустым экраном и не сообщением браузера.
@@ -102,11 +128,18 @@
## Что не решено
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
ходу.
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
Проверено прогоном 2026-08-15.
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
готовый способен удвоить собранный файл
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
узнала»).
- **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md).
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md).
+405 -122
View File
@@ -1,169 +1,452 @@
# Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
База, таблицы, раскладка файлов, правило времени и идентификаторов.
Хранилище **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
CGO сборке не нужен.
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
задача `storage-without-pocketbase`,
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
шаг не переписывается — изменение только новым шагом: применённое хранилище
считает по имени шага.
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
правила, получают отказ по занятости на записи результата шага, то есть после
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
читатели не мешают писателю.
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
Применённый шаг не переписывается — изменение только новым шагом.
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
чтения каталога; две одинаковых версии дают отказ сбора.
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
запирателя накатывает без всякой блокировки. Замок берётся на файле
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
который надо чистить руками, не остаётся.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути**, а префикс наводится только на каталог. Где этот префикс задан —
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
считается несуществующей записью и до базы не доходит.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
файла внутри него — `<ULID><расширение>`.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
**Время** — `TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
## Коллекции
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
вставка, забывшая проставить время, при нём прошла бы молча.
## Таблицы
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
владельца правила запись руками и вправе была завести значение, которого сервис
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
### `users`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID, выдаёт приложение |
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
| `email` | TEXT | Адрес почты; необязателен |
| `created_at`, `updated_at` | TEXT | Время |
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
записи без почты уживаются друг с другом. Уникальность логина — обычным.
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
досталось бы чужой записи.
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — каждая своей записью.
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `size` | INTEGER | Размер в байтах |
| `created`, `updated` | DATETIME | Проставляет хранилище |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
| `size_bytes` | INTEGER | Размер копии в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created_at` | TEXT | Время |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
### `transcribe_jobs`
### `audio_records`
Задача расшифровки и она же очередь.
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
| `source` | select | `api`, `telegram`, `unknown` |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | editor | Результат распознавания |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | TEXT | Не брать запись раньше этого времени |
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
| `structure_id` | TEXT | Структура реплик |
| `recognition_id` | TEXT | Попытка распознавания |
| `created_at`, `updated_at` | TEXT | Время |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
Индексов два. `idx_audio_records_acquire` `(state, halted_at, created_at, id)`:
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
`idx_audio_records_owner_page``(owner_id, created_at, id)`: под страницу
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
потребует нового шага.
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
сканирования таблицы.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
страница сканировала таблицу целиком и досортировывала результат во временном
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
хранит их бессрочно.
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
файла на диске и в журнал оно по-прежнему не идёт.
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
а список читается без содержимого. Решение владельца от 2026-08-15.
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `record_topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `record_id` | TEXT → `audio_records(id)` | Запись |
| `topic_id` | TEXT → `topics(id)` | Тема |
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
Число берётся у домена — то же самое, которое сервис объявляет приложению.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
| `kind` | TEXT | `transcript` или `literary` |
| `contents` | TEXT | Сам текст |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
файла.
### `structures`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | TEXT | Реплики со временем, JSON |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
### `recognitions`
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
| `started_at`, `finished_at` | TEXT | Границы операции |
| `created_at`, `updated_at` | TEXT | Время |
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
каждом опросе. Хранится он потому, что результат операции у провайдера не
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
| `origin` | TEXT | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
| `created_at` | TEXT | Время |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
журнала не осталось.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
| `name` | TEXT | Название темы |
| `created_at`, `updated_at` | TEXT | Время |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
считающая темы, не платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
«применённая миграция не переписывается», решением владельца от 2026-08-22:
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
годятся вовсе. Снятие кончается этим шагом.
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
одним новым шагом схемы.
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
причине:** их держал применённый шаг, которого больше не существует.
**Учётная запись с записями не удаляется**, и держит это схема обязательной
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
тупик до задачи про удаление записи.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом чтении задачи и при каждом захвате.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
сужает: создание записи разрешено только контексту обмена OIDC
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением.
- **Файлы записи лежат подкаталогом на запись:**
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
убирается целиком одним движением; плоский каталог, где копии различаются
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
растут неограниченно.
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Строка о файле заводится **после** этого;
содержимое легло, а строка не сохранилась — уложенный файл убирается.
- **Файл отдаётся адресом приложения**
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
протухания захвата и отсутствию признака остановки; срок протухания выбирается
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
подставить его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан четырьмя местами**`applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
в Object Storage: отказ SDK несёт полный URL объекта.
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
запросе правки, поэтому между проверкой и записью не остаётся окна.
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
который компилируется молча и кладёт идентификатор файла в колонку текста.
Перечень мест, где правится колонка, и серьёзность правила — инвариант
«Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md),
«Инварианты»; сверку держат правила `internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором**`internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
полный URL объекта.
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
Отменять там нечего: операции местные и короткие, а единственное ожидание —
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
распознавания.
## Настройки с числовым значением
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(отказ1)` с, потолок 5 минут | там же | то же |
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
| Предел простоя, своя работа | 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` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает. Как читается цепочка — спека
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
запроса приёма, и назван дважды: объявленная длина судится заранее, а
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
+49 -38
View File
@@ -21,12 +21,14 @@
| --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
несколько часов через Telegram не проходит вовсе.
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
приложение: диктофонная запись на несколько часов через Telegram не проходит
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
учётной записью. Вместе с ним из потребителей ушёл пользователь
Telegram.
Цель достигнута, когда:
@@ -35,8 +37,8 @@
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
узнаёт о его готовности, не держа приложение открытым;
- текст доступен там же, где загружали. Человек узнаёт о его готовности, не
держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится
@@ -59,20 +61,30 @@
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
входит своим паролем, потому что подпустить к ней внешнего провайдера
PocketBase не даёт.
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Панель администратора со своим паролем владельца жила
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
выключенным умолчанием, а границу его держит спека
[access](../openspec/specs/access/spec.md).
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем.
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
записи в модели данных не существует, и всякий вошедший видит все записи
([security.md](security.md), «Периметр»). Это состояние, а не решение;
закрывает его `record-ownership`.
доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
есть владелец, и чужую по её идентификатору не отдают
([security.md](security.md), «Периметр»). Закрыла это задача
`record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв
@@ -80,7 +92,9 @@
## Типовые сценарии
Первые два — основные, и сегодня не работает ни один: приложения нет.
Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
@@ -90,21 +104,17 @@
2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями. Работает сегодня.
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
его заводит `api-tokens`, — как нет и разграничения записей между
пользователями.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить.
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
токеном, получает идентификатор записи и читает её карточку
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
Своего способа представиться у программы нет его заводит `api-tokens`.
Записи при этом разграничены: видны только записи того, чьим именем пришли.
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
получает признак остановки с причиной, и карточка записи отдаёт признак и
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
## Референсы
@@ -114,10 +124,11 @@
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
задача `pocketbase-storage` 2026-08-12
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
держит хранилище, файлы и панель владельца. Схема и
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
Authelia своим провайдером OIDC, но источником их не становится: заводит и
проверяет людей по-прежнему Authelia.
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
перевела её в стек; стеком — 2026-08-22, когда задача
`storage-without-pocketbase`
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
[database.md](database.md).
+8
View File
@@ -20,8 +20,16 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
## Записи
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
сказано.
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
+44 -3
View File
@@ -1,17 +1,27 @@
# PocketBase: умолчания, которые ломают штатный сценарий
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
Живое из записки переехало в [../database.md](../database.md), «Настройки с
числовым значением», — потолок размера одной записи, потолок тела запроса и
снятый таймаут чтения, — и в
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
@@ -86,6 +96,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Обязательность связи проверяется у записи, а не у колонки
`Required` у поля связи — правило **проверки записи при сохранении**, а не
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
```
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
core/collection_validate.go ни одной проверки, читающей существующие строки
```
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
и вывод:
```
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
включая то, которым ставится признак остановки. Искать такие строки надо запросом
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
отличает. Цена решения записана в
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
@@ -96,3 +134,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+54
View File
@@ -1,5 +1,11 @@
# PocketBase: что даёт панель администратора
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах.
@@ -169,6 +175,20 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
""` → `FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
ней стоит уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
временного каталога — чтение исходников давало ту же цепочку, но противоположную
развязку.
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
@@ -186,3 +206,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
способов:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких.
## Разграничение по владельцу: что выяснилось при реализации
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change.
**Связь с выключенным каскадом не удерживает целостность при удалении.**
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
необязательном поле хранилище **вынимает** идентификатор из поля связи и
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
sure that the record is not part of a required relation reference.", err))`, а
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
из другой — например, владелец панели — узнан, но не годится, и код отказа это
различает.
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.
+2 -2
View File
@@ -25,8 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
одинаковость экрана.
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
столько экранов заводит задача
[spa-skeleton](../../tasks/items/spa-skeleton.md). Роутеры
столько экранов предполагала задача `spa-skeleton`, сделанная 2026-08-15.
Роутеры
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
- **Размеры**`stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
+127
View File
@@ -0,0 +1,127 @@
# Хранилище: PocketBase против голого SQLite с каталогом файлов
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
постановки.
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
нет.
## Как снималось
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
репозитории, боевые данные не участвовали.
- **Пакеты в сборке:** `go list -deps ./... | wc -l`**584**; из них с
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase`**40**.
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
- **Узлы для варианта с монтированием** — чтением кэша модулей:
`router.Router.BuildMux()` отдаёт `http.Handler`
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
только их существование.
## PocketBase здесь — фреймворк приложения, а не хранилище
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
шесть ролей сразу, и только две из них про хранение.
| Роль | Где | Чем заменяется |
| --- | --- | --- |
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
| Панель `/_/` | покупалась ради неё | ничем |
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
## Три довода прежнего решения: один умер 2026-08-22
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
порознь ни одна перевода не оправдывает.
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
нет замены.
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
же ADR оговаривает, что копии сервер делает своими средствами.
3. **Вход через её провайдер OIDC****отпал**. Цитата, которой ADR отверг
вариант «взять библиотеку только хранилищем»:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
Вход делает само приложение с 2026-08-22
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
пришедшего называет заголовок прокси, а учётную запись заводит наш
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
перестал быть верным — и вместе с ним отпало основание держать вход в
библиотеке.
## Что оплачено и не работает
- **Захват записи идёт сырым запросом мимо записей коллекции**
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
На самом горячем месте абстракция не помогает, а её ограничения действуют —
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
сравниваются строками побайтово.
- **Налог соседства двух периметров в одном процессе и на одном порту.**
Появился секрет, которого не было, — пароль суперпользователя. Панель
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
абзацем в `identity.go`.
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
закрыть можно всё пространство хранилища разом.
## Чем платит уход
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
там.
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
единственным соединением, и библиотека держит это за нас двумя пулами.
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
На стройке цена нулевая: на сервере пусто, переносить нечего.
## Что уходит из сборки, а что остаётся
`go mod why -m` показал, что шесть модулей достижимы **только** через
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
## Варианты и что выбрано
| Вариант | Что делает | Чем платит |
| --- | --- | --- |
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
запросом к базе. Решение записано в
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.
+67
View File
@@ -0,0 +1,67 @@
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
самой библиотеки.
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
дающий.
## Как снималось
Прогонами на зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
2026-08-23, отчёт —
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
- **Модульный.** Вход
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"`
опечатка в имени секции.
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
каталог данных вне репозитория; проба `curl /app/me`.
## Что выяснилось
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
путей, которых структура не знала: `[auth.test_headrs
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
загрузка настроек, ни проверки старта.
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
ловит проверка старта — `auth: секция [auth.test_headers] называет
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
доходит.
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
`INFO "Incoming request" … http.status_code=401`.
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
поднимается. Свойство держится ровно на тишине выше: перечень
`MetaData.Undecoded()` никто не судит.
## Что из этого следует для кода
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
надо ли его менять.
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
снимает безопасность отката, потому что прежний образ перестанет поднимать
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
а не попутная правка.
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
+69
View File
@@ -0,0 +1,69 @@
# Раздача приложения: что делают за нас библиотека и сборщик
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
## Как снималось
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
## Что выяснилось
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
— правилом обратного прокси, — такой формы не видит. Цена записана в
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
обходится подменой одного знака.
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
целиком
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
хранить пять суток, адрес записывать. Готовая раздача статики
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
делает.
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
положенная рядом с собранным ради того, чтобы каталог существовал в git,
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
- **Проверка типов однофайловых компонентов не работает с седьмой линией
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
пара — пятая линия TypeScript.
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
даёт зависание. Помогает короткое обращение-проба перед установкой.
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
экрана в разведке `spa-framework` весило того же порядка.
## Чего эта записка не узнала
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
финальный образ от неё не растёт вовсе.
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
и заголовками, а не браузером.
+371 -86
View File
@@ -2,10 +2,11 @@
## Как настроен конвейер
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
`triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
ранний — `fix-http-handler-tests` 2026-08-11, самый поздний —
`start-without-telegram-token` 2026-08-13.
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
`code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
прогоном, и смотреть его надо в самом архиве.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
@@ -31,6 +32,13 @@
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Состав прогона при этом был самым широким из
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
@@ -51,14 +59,34 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
**Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
шаг `front`):
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
корней порождает регистрацию маршрутов, а не описывает её;
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
кодом `200`;
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
сборщика: отозвать его у браузера сервису нечем;
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
нет» отличается от «файла нет»;
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
**отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
пишется дословно ни под каким корнем, включая корень приложения.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -66,17 +94,20 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
**именами**: именованный параметр запроса и место назначения по имени, а не
позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
@@ -99,6 +130,18 @@
### Типовые ложноположительные
- **«Хранилище молча сливает две учётные записи с одной почтой в одного
владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
признак он не может — уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
`Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
без прогона, здесь и измерена.
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Норма записана требованием
@@ -109,25 +152,51 @@
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Механика захвата и её слабые места — [database.md](database.md),
«Представление данных». Находка становится настоящей ровно тогда, когда
появится второй экземпляр процесса или второй воркер на то же состояние.
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
разграничения по владельцу нет сознательно — [security.md](security.md),
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без**
сессии, а не повторение этого факта.
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
иначе выбросил бы настоящую находку как известную.
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
он: заголовки входа подставляет сам сервис под предохранителем
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
закрыт той же задачей:** норма о подставных собеседниках записана в
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
Вопрос при этом остаётся вопросом:
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
(ревью задачи про заглушку OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
@@ -136,10 +205,10 @@
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
@@ -152,16 +221,27 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
под маркерами долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
2026-08-15 про единую форму отказа).
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
@@ -184,44 +264,57 @@
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки
### Когда звать глубокое ревью
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
здесь только места этого проекта.
**Крупное здесь** (поднимает до `large`, ось объёма):
**Смотрим целиком** (область кода, а не дифф задачи):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
- **вход и разграничение доступа** — звенья `internal/controller/http`
(узнавание, требование учётной записи, ограничитель частоты, подстановка
заголовков отладочного запуска) вместе со спекой
[access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
прогоне нет (см. «Недоступно проверке»);
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
«Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
«Инварианты», major): места, цена забытого и то, чем держится сверка, названы
там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
`internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
сервиса, хоронившая конвертируемую запись;
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
проверке»), и разбор остаётся единственным способом судить о нём.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
**Необратимое здесь.** Перечень необратимого один и лежит в
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
пункты касаются, и правило прохода: находка в таком месте уходит человеку
развилкой, а не чинится молча.
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
собой — форма решения нащупывается по ходу;
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
- применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
- формат файла на диске и раскладка каталога данных —
`internal/adapter/repo/sqlite`, `store.go`;
- публичный контракт HTTP API — `internal/controller/http`;
- имя ключа конфига — `internal/config`;
- действие с боевыми данными и с Yandex Cloud, ротация секрета —
`internal/adapter/recognizer/yandex`.
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
там только следующей записи.
### Недоступно проверке
@@ -233,13 +326,15 @@ API и имя не откатываются обратной правкой по
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
выкладки вне репозитория, и по коду её не проверить
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками — применение `SameSite`, приём
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
этого рода остаются гипотезами.
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
как возврат недоступного проверке, а не как обычная работа.
**Перестали проверять сознательно:**
@@ -249,19 +344,28 @@ API и имя не откатываются обратной правкой по
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
сменился с пустого ключа доступа на выключенный вход задачей
`telegram-enabled-flag` того же дня.
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
предметов осмотра нет с 2026-08-22.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
расшифровку и заливку.
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку.
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
заведение учётной записи первым обращением, отказ с недоверенного адреса и
отказ старта на пустом перечне.
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
контуре (см. «Не проверит ни один проход»).
## Журнал дефектов
@@ -271,6 +375,187 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]
- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
`storage-without-pocketbase`
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
Путь под корнем приложения уходил в строку дословно — в том числе при ответе
`401`, потому что слой журнала стоит снаружи ограничителя частоты
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
под корнем распоряжается тот же аноним
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
- **Почему не поймали раньше:** правило записали по месту, где его впервые
понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
Зазор был ровно шириной в корень приложения
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
переписано на все корни разом — оно стоит теперь у строки о всяком входящем
запросе, а не у строки о раздаче приложения
## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]
- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
`storage-without-pocketbase`
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
адресов
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
заголовок дописывает, а не заменяет. Левым значением распоряжается сам
спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
запросе
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
конвенции, ни в типовом узле — его держала прежде чужая библиотека
- **Что меняем:** цепочка читается справа налево, доверенные адреса
отбрасываются, ключом становится первый недоверенный, а заголовок читается
всеми строками, а не одной. Требование к контуру этим снято: дописывающий
прокси правилом покрыт — [security.md](security.md), «Периметр»
## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]
- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
`internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
на инвариант как на оракул стало нельзя
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
инварианта остался от прежней. Мест при этом стало три: что спрошено
(`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
(`rowToAudioRecord`), — а сверялось правилом одно
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
`rowToAudioRecord` в `internal/archrules` — пусто
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
тексте — ничем. Текст и сторож разошлись молча
- **Что меняем:** инвариант назван действующими именами и действительным числом
мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
колонок чтения сверяется с перечнем присвоений в сущность
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
журнале хранилища
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
а признак провайдера на записи уникален — `idx_externalAuths_record_provider`
и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
из `sub`
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
читатели именно её
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
затем счёт записей в коллекции пользователей. Само умолчание почты теперь
выводится из `-sub`
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
задача `spa-skeleton`
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
суток. При этом строка `docs/review.md`, добавленная той же задачей,
утверждала, что путь анонима в журнал не идёт
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
признак при переписывании не перенесён.
Журналов у сервиса два, а сделанная защита закрыла один
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
один тест туда не смотрел
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
два отказа оставляют
## 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`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
+273 -141
View File
@@ -3,19 +3,39 @@
## Периметр
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра.
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
`trusted-header-login`, называет она его **заголовком, который ставит обратный
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
**каждый** запрос, и отзыв доступа действует со следующего.
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
открытость не касается — всякий адрес под корнем приложения узнанного
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
браузеру сервису нечем.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
`record-ownership`.
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
записью приложения он не связан.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -23,29 +43,103 @@
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панели не существует, второго периметра на порту сервиса не осталось, и правилу
прокси нечего закрывать.
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
секрет в настройки коллекции пользователей, и чтение файла базы становилось
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
записью навсегда: колонка уникальна, а найденную запись узнавание не
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
правило решено распространить на всю тройку отдельной задачей. Остальное
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
репозитория `pet-project-server`, и **требование к нему такое — заголовки
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
задачей `config-test-headers-login`. При включённом предохранителе
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
самим сервисом —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
Держится оно тремя вещами, и других нет: умолчание предохранителя —
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
входу это не служит: перечень доверенных адресов включению предохранителя не
мешает.
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
Как именно читается цепочка, нормирует спека
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
пространством». Отсюда периметровое следствие: прокси, дописывающий
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
именно поэтому.
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
после ухода прежнего владельца, и тогда новый человек при первом же обращении
попадает в **существующую** запись и получает весь её архив — самое
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
может: неизменяемого признака заголовок не приносит. Не допускать
переиспользования — работа провайдера, и это принятая цена, записанная в
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
нечем ни слить, ни убрать.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
на распознавание столько, сколько захочет.
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.
## Недоверенный вход
@@ -53,11 +147,12 @@
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей
@@ -66,7 +161,6 @@
| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
@@ -78,9 +172,10 @@
## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
Telegram отправителю.
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
приложения.
Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -100,32 +195,40 @@ Telegram отправителю.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает хранилище:
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
отправителя, не применяется: имя отправителя в хранилище не попадает.
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл**`/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
журнал, ни в метку метрики, ни в ответ.
- **Адрес файла**`GET /app/audiorecords/{id}/file?copy=original|normalized`.
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
идёт расширение своим полем.
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -140,81 +243,110 @@ Telegram отправителю.
## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»).
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
доходит до сервиса, — не значил бы ничего.
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
та же проверка, но она названа здесь отдельно, потому что это второй способ
предъявить ту же сессию.
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
здесь «сессия → токен файла → ссылка».
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения.
Собственных токенов сервис не принимает вовсе: значения, предъявленного
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
значение било заголовок — им работал владелец панели; панели нет, и правило
приоритета осталось бы правилом без предмета.
**Область узнавания — корень приложения**, и выводится она из объявленного
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
библиотечным правилом, не осталось.
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
- **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Заведение учётной записи** — только входом у провайдера. Собственное
создание записи, вход по паролю, одноразовый код и восстановление доступа
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
без этого закрытия вход обходился бы двумя запросами.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты без сессии:
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
больше нечего, и адресов этих не существует.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
частоты на них нет: правило заведено под корень приложения, а раздача стоит
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
кто-то, — узнанному и неузнанному отдаётся одно и то же.
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
для всякого встречного.
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
есть владелец. Знание идентификатора задачи правом её читать больше не является
— читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено»**сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
сервиса осталось одно — владение записью.
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей** — сам по себе перечень имён.
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
из базы вместе с собственным входом.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
**Строки `.env` в `.gitignore` и в `.dockerignore` стоят без читателя, и снимать
их поэтому нельзя.** Читателя сняли 2026-08-23 вместе с зависимостью
`godotenv`: наш рабочий код окружение не читает, и файл, положенный рядом с
бинарником, ничего не меняет. Барьеры остались против другого — против того,
чтобы секрет завёлся в этом файле руками и уехал из него теми же двумя путями,
какими уехал бы из конфига: в git и в контекст сборки образа. Второй барьер
нужен отдельно от первого: `Dockerfile` копирует корень целиком (`COPY . .`), а
`.gitignore` docker не читает — чужой `.env` лёг бы слоем образа. Инвариант,
ради которого барьеры стоят, — «Секрет не покидает конфиг» из
[../CLAUDE.md](../CLAUDE.md).
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
секрета, которого сегодня в проекте нет вовсе:
@@ -230,14 +362,10 @@ Telegram отправителю.
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
пользователей разом, то есть стоит вровень с самым чувствительным из списка
выше. Второй секрет после токенов пользователей, который лежит **не в
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
заводить нечего.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -260,36 +388,20 @@ Telegram отправителю.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт.
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
клиента — закрыты задачами `no-user-filename-in-log` и
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
свой `Do``internal/adapter/telegram`, `NewBot`: он чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
независимо; оракул — там же, в `bot_test.go`.
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
@@ -308,6 +420,16 @@ Telegram отправителю.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
сам сервис: при включённом предохранителе `[server] debug` он подставляет
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
`./cmd/transcriber` поимённо.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
@@ -332,6 +454,16 @@ Telegram отправителю.
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
деньги потрачены, а строки потребления текста не содержат.
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
сперва строки приложений — журнал событий, попытка распознавания, структура,
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
`delete-record` это единственный способ, и он ручной целиком.
+13 -31
View File
@@ -10,19 +10,17 @@ require (
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.7
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.39.10
github.com/pressly/goose/v3 v3.27.3
github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.57.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
@@ -39,44 +37,28 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/image v0.44.0 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
)
+41 -99
View File
@@ -1,10 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -47,149 +42,97 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -197,11 +140,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
@@ -212,8 +154,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -222,8 +164,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+64 -7
View File
@@ -2,22 +2,79 @@ package recognizer
import (
"context"
"encoding/json"
"errors"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
// и хранение в Object Storage оплачиваются по факту.
//
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
// строится без единого обращения наружу.
type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
const memoryProvider = "memory"
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
return "memory://" + objectKey, nil
}
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return false, nil
}
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
return uuid.NewString(), nil
}
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil
}
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
replicas := []entity.Replica{
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
}
raw, err := json.Marshal(replicas)
if err != nil {
return nil, errors.New("failed to encode memory payload")
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: "Foo bar, Baz.",
Raw: raw,
}, nil
}
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("failed to decode memory payload")
}
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: string(plain),
Raw: raw,
}, nil
}
@@ -0,0 +1,138 @@
package yandex
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"google.golang.org/protobuf/proto"
)
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
// сохранность самого ответа.
// response собирает ответ потока с одной репликой.
func response(text string, start, end int64) *stt.StreamingResponse {
return &stt.StreamingResponse{
Event: &stt.StreamingResponse_FinalRefinement{
FinalRefinement: &stt.FinalRefinement{
Type: &stt.FinalRefinement_NormalizedText{
NormalizedText: &stt.AlternativeUpdate{
Alternatives: []*stt.Alternative{
{Text: text, StartTimeMs: start, EndTimeMs: end},
},
},
},
},
},
}
}
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
func TestPayloadSurvivesRoundTrip(t *testing.T) {
responses := []*stt.StreamingResponse{
response("Первая реплика.", 0, 900),
response("Вторая реплика.", 900, 1800),
}
raw, err := encodeResponses(responses)
require.NoError(t, err)
require.NotEmpty(t, raw)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 2)
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 2)
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
}
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
// без предупреждения, и текстовое представление, собранное по нашей
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
// уже не из чего: операция не переспрашивается.
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
original := response("Реплика.", 0, 500)
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
// разбор положил в неизвестные.
unknown := protoimplUnknown(t)
original.ProtoReflect().SetUnknown(unknown)
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
raw, err := encodeResponses([]*stt.StreamingResponse{original})
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 1)
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
"неизвестное провайдерское поле пережило запись и чтение")
// И известное при этом на месте.
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 1)
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
}
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
// текста, выданная за целую, тише и хуже отказа.
func TestTruncatedPayloadIsRefused(t *testing.T) {
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
require.NoError(t, err)
require.Greater(t, len(raw), 2)
_, err = decodeResponses(raw[:len(raw)-2])
assert.Error(t, err, "обрезанное вложение не разбирается молча")
}
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
// законный исход распознавания.
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
raw, err := encodeResponses(nil)
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
assert.Empty(t, decoded)
outcome := outcomeFromResponses(decoded)
assert.Empty(t, outcome.Replicas)
assert.Empty(t, outcome.PlainText)
}
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
// потоку и служебные события.
func TestResponseWithoutTextIsSkipped(t *testing.T) {
responses := []*stt.StreamingResponse{
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
response("Реплика.", 0, 500),
response("", 500, 600),
}
outcome := outcomeFromResponses(responses)
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
assert.Equal(t, "Реплика.", outcome.PlainText)
}
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
// нашей схеме нет, с целочисленным значением.
func protoimplUnknown(t *testing.T) []byte {
t.Helper()
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
raw, err := proto.Marshal(&stt.StreamingResponse{})
require.NoError(t, err)
require.Empty(t, raw)
return []byte{0xF8, 0xFF, 0x3F, 0x07}
}
@@ -9,6 +9,10 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
const ProviderName = "yandex-speechkit"
type YandexAudioRecognizerConfig struct {
// s3
Region string
@@ -56,36 +60,44 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
// объект ложится под тем же ключом.
err := s.s3Sevice.uploadFile(ctx, file, fileName)
if err != nil {
// Upload кладёт аудио туда, откуда провайдер его прочитает.
//
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
// ложится под тем же ключом.
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
return "", err
}
return s.s3Sevice.fileUrl(objectKey), nil
}
uri := s.s3Sevice.fileUrl(fileName)
// ObjectExists отвечает, лежит ли объект нужного размера.
//
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
// расхождение на всякой большой записи.
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return s.s3Sevice.objectExists(ctx, objectKey, size)
}
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
// вечно.
// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
if err != nil {
return "", err
}
return opId, nil
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
}
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
@@ -95,11 +107,26 @@ func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Cont
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return s.sttService.getRecognitionText(ctx, operationID)
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
return s.sttService.fetchRecognition(ctx, operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
// провайдеру. По нему архив пересчитывается без единого рубля.
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
responses, err := decodeResponses(raw)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
if err != nil {
return nil, err
+32
View File
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileNa
return nil
}
// objectExists отвечает, лежит ли объект нужного размера.
//
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
// отпечатку содержимого: признак целостности у составного объекта не равен
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
var notFound *s3types.NotFound
if errors.As(err, &notFound) {
return false, nil
}
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
// идёт класс отказа и только он.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
}
return false, errors.New("failed to head object in S3")
}
return out.ContentLength != nil && *out.ContentLength == size, nil
}
func (s *yandexS3Service) fileUrl(fileName string) string {
endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
+112 -17
View File
@@ -2,17 +2,20 @@ package yandex
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
const (
@@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string
return op.Id, nil
}
// GetRecognitionResult получает результат распознавания по ID операции
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
// вместе с сырым ответом на хранение.
//
// Ответ сохраняется потому, что **результат операции у провайдера не
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -146,11 +153,10 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
stream, err := s.sttClient.GetRecognition(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to get recognition stream: %w", err)
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
}
var sb strings.Builder
var responses []*stt.StreamingResponse
for {
resp, err := stream.Recv()
if err != nil {
@@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
if errors.Is(err, io.EOF) {
break
}
return "", fmt.Errorf("failed to receive recognition response: %w", err)
}
if refinement := resp.GetFinalRefinement(); refinement != nil {
if text := refinement.GetNormalizedText(); text != nil {
for _, alt := range text.Alternatives {
sb.WriteString(alt.Text)
sb.WriteString(" ")
}
}
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
responses = append(responses, resp)
}
return sb.String(), nil
raw, err := encodeResponses(responses)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
// checkOperationStatus проверяет статус операции распознавания
@@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID
return op, nil
}
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
//
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
// результат операции у провайдера не переспрашивается, и повторное
// распознавание стоит денег.
//
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
// кроме нашего же кода.
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
var raw []byte
for _, resp := range responses {
encoded, err := proto.Marshal(resp)
if err != nil {
// Текст расшифровки наружу не выходит даже отказом: сообщение
// провайдера несёт её целиком.
return nil, errors.New("failed to encode provider response")
}
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
raw = append(raw, encoded...)
}
return raw, nil
}
// decodeResponses читает сохранённый ответ провайдера обратно.
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
var responses []*stt.StreamingResponse
for len(raw) > 0 {
size, read := binary.Uvarint(raw)
if read <= 0 || uint64(len(raw)-read) < size {
return nil, errors.New("stored provider payload is truncated")
}
raw = raw[read:]
var resp stt.StreamingResponse
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
return nil, errors.New("failed to decode provider response")
}
responses = append(responses, &resp)
raw = raw[size:]
}
return responses, nil
}
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
// текст. Формата провайдера отсюда наружу не выходит ничего.
//
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
// станет возможной без повторной оплаты.
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
outcome := &entity.RecognitionOutcome{}
var plain []byte
for _, resp := range responses {
refinement := resp.GetFinalRefinement()
if refinement == nil {
continue
}
text := refinement.GetNormalizedText()
if text == nil {
continue
}
for _, alt := range text.GetAlternatives() {
if alt.GetText() == "" {
continue
}
outcome.Replicas = append(outcome.Replicas, entity.Replica{
StartMs: alt.GetStartTimeMs(),
EndMs: alt.GetEndTimeMs(),
Text: alt.GetText(),
})
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, alt.GetText()...)
}
}
outcome.PlainText = string(plain)
return outcome
}
-58
View File
@@ -1,58 +0,0 @@
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
//
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
// контракт запуска.
package pocketbase
import (
"fmt"
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
// Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
// `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
// пакета и так берут оттуда имена коллекций: день, когда имена перестанут
// читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
// шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
// первом приёме записи.
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
// шаги схемы.
//
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
func New(dataDir string) (*pb.PocketBase, error) {
app := pb.NewWithConfig(pb.Config{
DefaultDataDir: dataDir,
HideStartBanner: true,
})
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
}
if err := app.RunAllMigrations(); err != nil {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
return app, nil
}
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
// потому что Serve накатывает схему прежде, чем поднять сервер.
func findCollection(app core.App, name string) (*core.Collection, error) {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
}
return collection, nil
}
@@ -1,271 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
type FileRepository struct {
app core.App
}
func NewFileRepository(app core.App) *FileRepository {
return &FileRepository{app: app}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
work, err := newWorkFile(filepath.Ext(name))
if err != nil {
return nil, err
}
src, err := repo.openStored(record, name)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer src.Close()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
stored, err := filesystem.NewFileFromPath(work.Path())
if err != nil {
return nil, fmt.Errorf("failed to read work file: %w", err)
}
stored.Name = name
record := core.NewRecord(collection)
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
// ключ при чтении.
return nil, errors.New("failed to store file")
}
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
return repo.openStored(record, name)
}
// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
if err != nil {
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
// стала бы там бессрочным ключом к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to read stored file of record %s", record.Id),
fsys.Close(),
)
}
return &storedReader{reader: reader, fsys: fsys}, nil
}
// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
reader io.ReadCloser
fsys io.Closer
}
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *storedReader) Close() error {
readerErr := r.reader.Close()
fsysErr := r.fsys.Close()
switch {
case readerErr != nil && fsysErr != nil:
return errors.New("failed to close stored file and its filesystem")
case readerErr != nil:
return errors.New("failed to close stored file")
default:
return fsysErr
}
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func firstFileName(record *core.Record) string {
names := record.GetStringSlice("file")
if len(names) == 0 {
return ""
}
return names[0]
}
func recordToFile(record *core.Record) *entity.File {
name := firstFileName(record)
if name == "" {
name = record.GetString("object_key")
}
return &entity.File{
Id: record.Id,
Location: record.GetString("location"),
FileName: name,
Size: int64(record.GetInt("size")),
CreatedAt: record.GetDateTime("created").Time(),
}
}
@@ -1,38 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
// иначе прошло бы молча.
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
const libraryDefault = 5 << 20
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
size, err := work.Size()
require.NoError(t, err)
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
file, err := repo.CreateLocal("big.mp3", work)
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
assert.Equal(t, size, file.Size)
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
}
@@ -1,203 +0,0 @@
package pocketbase
import (
"database/sql"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
// расхождение проявлялось как потерянное при сохранении поле.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
// каким входом пришла запись, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
// правка на месте.
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
record.Set("state", job.State)
record.Set("file", derefString(job.FileID))
record.Set("error_text", derefString(job.ErrorText))
record.Set("acquisition_id", derefString(job.AcquisitionID))
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
record.Set("delay_time", dateOrEmpty(job.DelayTime))
record.Set("attempts", job.Attempts)
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
record.Set("transcription_text", derefString(job.TranscriptionText))
}
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
}
func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
AcquisitionID sql.NullString `db:"acquisition_id"`
AcquireTime sql.NullString `db:"acquire_time"`
DelayTime sql.NullString `db:"delay_time"`
Attempts int `db:"attempts"`
RecognitionOpID sql.NullString `db:"recognition_op_id"`
TranscriptionText sql.NullString `db:"transcription_text"`
TgChatId sql.NullInt64 `db:"tg_chat_id"`
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
Created sql.NullString `db:"created"`
Updated sql.NullString `db:"updated"`
}
func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
AcquisitionID: nullToPtr(r.AcquisitionID),
AcquireTime: parseTimeOrNil(r.AcquireTime),
DelayTime: parseTimeOrNil(r.DelayTime),
Attempts: r.Attempts,
RecognitionOpID: nullToPtr(r.RecognitionOpID),
TranscriptionText: nullToPtr(r.TranscriptionText),
}
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
chatId := r.TgChatId.Int64
job.TgChatId = &chatId
}
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
msgId := int(r.TgReplyMessageId.Int64)
job.TgReplyMessageId = &msgId
}
if created := parseTimeOrNil(r.Created); created != nil {
job.CreatedAt = *created
}
if updated := parseTimeOrNil(r.Updated); updated != nil {
job.UpdatedAt = *updated
}
return job
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nullToPtr(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
s := v.String
return &s
}
func parseTimeOrNil(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
date, err := types.ParseDateTime(v.String)
if err != nil || date.IsZero() {
return nil
}
t := date.Time()
return &t
}
@@ -1,109 +0,0 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
// готовности задачи, а не ниже.
//
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
// минут. Число выведено из расчётного потолка записи в шесть часов с
// запасом на видео; оно же стоит строкой в docs/database.md.
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
// Где лежит копия. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
// разным вещам.
&core.SelectField{
Name: "location",
Values: []string{entity.LocationLocal, entity.LocationS3},
MaxSelect: 1,
Required: true,
},
// Ключ объекта во внешнем хранилище; у местной копии пуст.
&core.TextField{Name: "object_key"},
&core.NumberField{Name: "size", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(files); err != nil {
return err
}
jobs := core.NewBaseCollection(JobsCollection)
jobs.Fields.Add(
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
// не должна попасть в выборку с состоянием, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: []string{
entity.StateCreated,
entity.StateConverted,
entity.StateTranscribe,
entity.StateDone,
entity.StateFailed,
entity.StateDead,
},
MaxSelect: 1,
Required: true,
},
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
// результат.
// Обязательна: задача без записи не может пройти ни одного шага, и
// заведённая в панели руками она дошла бы до шага только затем, чтобы
// отказать. Компилятор этого не держит — держит схема.
&core.RelationField{
Name: "file",
CollectionId: files.Id,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "error_text"},
&core.TextField{Name: "acquisition_id"},
&core.DateField{Name: "acquire_time"},
&core.DateField{Name: "delay_time"},
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
// завершившемся без отказа.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.TextField{Name: "recognition_op_id"},
&core.EditorField{Name: "transcription_text"},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
// состоянию снимает полный перебор, который был у прежней таблицы.
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
return app.Save(jobs)
}
func down202608110001(app core.App) error {
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return err
}
}
return nil
}
@@ -1,124 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
const defaultAuthTokenDuration = 1209600
// up202608120001 закрывает поверхность, которую хранилище приносит своим
// системным шагом, и защищает файл записи.
//
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
// первому же настоящему входу с этим адресом.
func up202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Завести учётную запись можно только входом у провайдера.
//
// Правило именно такое, а не `nil`: запись при первом входе заводит
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
// а посторонний запрос приходит с контекстом по умолчанию.
//
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
// провайдера задал бы их сам.
users.CreateRule = ptr(`@request.context = "oauth2"`)
users.PasswordAuth.Enabled = false
users.OTP.Enabled = false
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
// секрет не пережил бы ротации — применённый шаг не переписывается.
users.OAuth2.Enabled = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to close users collection surface: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
// он читает файл из файловой системы хранилища, а не по ссылке.
//
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
// отменяется здесь: право прочитать запись больше не даёт знание её
// идентификатора.
field, ok := files.Fields.GetByName("file").(*core.FileField)
if !ok {
return errors.New("files collection has no file field")
}
field.Protected = true
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
// коллекции, а незаданное правило означает «только владелец панели» — файл
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
// записи ещё нет, и сужать выборку эта задача не должна.
files.ViewRule = ptr(`@request.auth.id != ""`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to protect record file: %w", err)
}
return nil
}
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
//
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
func down202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.CreateRule = nil
users.PasswordAuth.Enabled = true
users.OTP.Enabled = true
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
users.AuthToken.Duration = defaultAuthTokenDuration
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
field.Protected = false
}
files.ViewRule = nil
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to unprotect record file: %w", err)
}
return nil
}
@@ -1,36 +0,0 @@
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
// заводят.
//
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по **имени шага**, а не по пути файла, поэтому имена в
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
//
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом,
// наводить его
// было не на что, и проверка молчала на всякой правке схемы.
package migrations
import (
pbmigrations "github.com/pocketbase/pocketbase/migrations"
)
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
// меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
}
func ptr[T any](v T) *T { return &v }
-38
View File
@@ -1,38 +0,0 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
// и правку из кода.
//
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
// от первого же отказа). Узнать об этом ему неоткуда.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next()
}
e.Record.Set("acquisition_id", "")
e.Record.Set("acquire_time", "")
e.Record.Set("delay_time", "")
e.Record.Set("attempts", 0)
return e.Next()
})
}
@@ -1,72 +0,0 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
const ProviderName = "oidc"
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
// применяется, потому что оно никем не выбрано.
//
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
// хранилище продолжило выдавать прежний.
const SessionDuration = 7 * 24 * 60 * 60
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
// коллекции.
type ProviderSettings struct {
AuthURL string
TokenURL string
UserInfoURL string
ClientID string
ClientSecret string
}
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
// к значениям конфига.
//
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
// только руками в панели.
//
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
// имя коллекции, а не значения.
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
// сроком жизни куки.
users.AuthToken.Duration = SessionDuration
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: ProviderName,
ClientId: settings.ClientID,
ClientSecret: settings.ClientSecret,
AuthURL: settings.AuthURL,
TokenURL: settings.TokenURL,
UserInfoURL: settings.UserInfoURL,
DisplayName: "Authelia",
}}
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
}
return nil
}
@@ -1,157 +0,0 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscriptJobRepository struct {
app core.App
}
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
return &TranscriptJobRepository{app: app}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, migrations.JobsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if job.Id != "" {
record.Id = job.Id
}
applyToRecord(record, job)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
job.Id = record.Id
job.CreatedAt = record.GetDateTime("created").Time()
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший задачу за время работы, получает
// LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: job.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, job)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
// пометка её захваченной идут вместе, и захваченная возвращается тем же
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
// этом запросе обошла бы единую точку молча.
now, err := types.ParseDateTime(clock.Now())
if err != nil {
return nil, fmt.Errorf("failed to parse current time: %w", err)
}
query := repo.app.DB().NewQuery(`
UPDATE {{` + migrations.JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + migrations.JobsCollection + `}}
WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
ORDER BY created, id
LIMIT 1
)
RETURNING ` + acquireColumns)
rotting, err := types.ParseDateTime(rottingTime)
if err != nil {
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
}
query.Bind(dbx.Params{
"acquisition_id": acquisitionId,
"now": now.String(),
"state": state,
"rotting": rotting.String(),
})
var row acquiredRow
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
}
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
}
return row.toJob(), nil
}
@@ -1,412 +0,0 @@
package pocketbase
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
// путём, каким это делает сервис при старте.
func newTestApp(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
func newFile(t *testing.T, app core.App) *entity.File {
t.Helper()
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
return file
}
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
t.Helper()
file := newFile(t, repo.app)
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, repo.Create(job))
return job
}
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
const racers = 3
var (
wg sync.WaitGroup
mu sync.Mutex
got []*entity.TranscribeJob
notFound int
)
start := make(chan struct{})
for i := 0; i < racers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
<-start
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
mu.Lock()
defer mu.Unlock()
if err != nil {
var missing *contract.JobNotFoundError
if assert.ErrorAs(t, err, &missing) {
notFound++
}
return
}
got = append(got, acquired)
}(i)
}
close(start)
wg.Wait()
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
assert.Equal(t, job.Id, got[0].Id)
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
}
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, first)
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
}
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
// собственные времена хранилища.
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record))
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
assert.Equal(t, job.Id, again.Id)
}
// Пауза держит задачу от выдачи, пока не кончится.
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
delay := time.Now().Add(time.Hour)
job.DelayTime = &delay
require.NoError(t, repo.Save(job, ""))
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
}
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
// задаче, брошенной вместе с процессом.
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
for expected := 1; expected <= 3; expected++ {
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
assert.Equal(t, expected, acquired.Attempts)
}
}
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
// потерянное поле.
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
chatId := int64(4242)
replyId := 17
opId := "operation-id"
text := "расшифровка"
file := newFile(t, app)
job := &entity.TranscribeJob{
State: entity.StateTranscribe,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
TgReplyMessageId: &replyId,
RecognitionOpID: &opId,
TranscriptionText: &text,
}
require.NoError(t, repo.Create(job))
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, acquired.Id)
assert.Equal(t, entity.StateTranscribe, acquired.State)
assert.Equal(t, entity.SourceTelegram, acquired.Source)
require.NotNil(t, acquired.TgChatId)
assert.Equal(t, chatId, *acquired.TgChatId)
require.NotNil(t, acquired.TgReplyMessageId)
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
require.NotNil(t, acquired.RecognitionOpID)
assert.Equal(t, opId, *acquired.RecognitionOpID)
require.NotNil(t, acquired.TranscriptionText)
assert.Equal(t, text, *acquired.TranscriptionText)
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
}
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
mine.MoveToState(entity.StateConverted)
err = repo.Save(mine, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
job.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
// захвату и умирает от первого же отказа, и не узнал бы об этом.
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
require.NoError(t, app.Save(record))
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
assert.Nil(t, after.DelayTime, "пауза снята")
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
// И ближайший захват задачу выдаёт.
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, again.Id)
}
// Обратная сторона того же правила, и она дороже: правила панели MUST не
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateConverted)
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Шаг ставит задержку опроса вместе со сменой состояния.
delay := time.Now().Add(10 * time.Second)
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
// сколько раз мы пробовали.
after.Attempts = 6
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
}
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
// API от имени владельца.
func patchRecord(t *testing.T, app core.App, recordID, body string) {
t.Helper()
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
owner := core.NewRecord(superusers)
owner.Set("email", "owner@example.com")
owner.Set("password", "ownerpassword123")
require.NoError(t, app.Save(owner))
token, err := owner.NewStaticAuthToken(time.Hour)
require.NoError(t, err)
router, err := apis.NewRouter(app)
require.NoError(t, err)
mux, err := router.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
}
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
// отказа в панели.
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
file := newFile(t, app)
chatId := int64(111)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
}
require.NoError(t, repo.Create(job))
// Шаг захватил задачу и работает.
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Владелец правит в панели поле, которого конвейер не касается.
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
// Шаг доработал и сохраняет свой снимок.
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
+172
View File
@@ -0,0 +1,172 @@
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
// каталогом.
//
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
// читается как «репозитории поверх SQLite» без знания кода.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
// выбран: сборка бинарника остаётся без компилятора C.
_ "modernc.org/sqlite"
)
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
const driverName = "sqlite"
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
// целиком, а не одним файлом.
const DatabaseFile = "transcriber.db"
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
// константой кода: крутят их при одном и том же отказе — «база занята» под
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
// пересборки образа.
type Settings struct {
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
BusyTimeoutMs int
// ReadConnections — сколько соединений держит читающий пул.
ReadConnections int
}
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (s Settings) Validate() error {
if s.BusyTimeoutMs <= 0 {
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
}
if s.ReadConnections <= 0 {
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
}
return nil
}
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
// перестал бы освобождать захват и писать причину остановки — то есть отмена
// ломала бы ровно ту уборку, ради которой она и делается.
//
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
// DB — база сервиса двумя пулами.
//
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
// получают отказ по занятости — на записи результата шага, то есть после
// оплаченной работы. Пул с одним соединением обращает их в очередь.
//
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
type DB struct {
// writer — единственное пишущее соединение. Через него идёт всякая
// операция, которая читает состояние и следом его пишет: транзакцию,
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
// лечит, ждать там нечего.
writer *sql.DB
// reader — пул чтения.
reader *sql.DB
}
// Writer отдаёт пишущее соединение.
func (db *DB) Writer() *sql.DB { return db.writer }
// Reader отдаёт читающий пул.
func (db *DB) Reader() *sql.DB { return db.reader }
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
//
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
// молча.
func Open(dataDir string, settings Settings) (*DB, error) {
if err := settings.Validate(); err != nil {
return nil, err
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(dataDir, DatabaseFile)
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
// операция, которая читает и следом пишет, иначе взяла бы читающую
// транзакцию и упёрлась бы в отказ при первой же записи.
writer, err := open(path, settings, "immediate")
if err != nil {
return nil, err
}
writer.SetMaxOpenConns(1)
writer.SetMaxIdleConns(1)
reader, err := open(path, settings, "deferred")
if err != nil {
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
}
reader.SetMaxOpenConns(settings.ReadConnections)
reader.SetMaxIdleConns(settings.ReadConnections)
db := &DB{writer: writer, reader: reader}
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
// то есть отказом каждого запроса вместо одной строки о причине.
if err := writer.PingContext(context.Background()); err != nil {
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
}
return db, nil
}
// open заводит один пул с общими настройками соединения.
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
query := url.Values{}
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
query.Add("_pragma", "journal_mode(WAL)")
query.Add("_pragma", "foreign_keys(1)")
query.Set("_txlock", txlock)
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
// закрытого пула отказом не считается.
func (db *DB) Close() error {
var errs []error
if db.reader != nil {
if err := db.reader.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
}
db.reader = nil
}
if db.writer != nil {
if err := db.writer.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
}
db.writer = nil
}
return errors.Join(errs...)
}
+554
View File
@@ -0,0 +1,554 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
return db, NewStore(dir), dir
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
t.Helper()
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err)
return account.ID
}
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
db, _, _ := newTestDB(t)
var mode string
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
var busy int
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
// поломка, ради которой настройка уехала в строку подключения.
var wg sync.WaitGroup
answers := make([]int, testSettings().ReadConnections)
start := make(chan struct{})
for i := range answers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conn, err := db.Reader().Conn(context.Background())
if !assert.NoError(t, err) {
return
}
defer func() { assert.NoError(t, conn.Close()) }()
assert.NoError(t,
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
// Соединение придерживается, пока спрашивают остальные: иначе пул
// раздал бы всем одно и то же и правило проверило бы одну настройку
// вместо четырёх.
time.Sleep(10 * time.Millisecond)
}()
}
close(start)
wg.Wait()
for i, answer := range answers {
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
}
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
// несуществующим владельцем отвергается обоими пулами.
now := clock.Now().Format(timeLayout)
insert := `INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
_, err := db.Writer().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
_, err = db.Reader().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
for name, settings := range map[string]Settings{
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
} {
t.Run(name, func(t *testing.T) {
_, err := Open(t.TempDir(), settings)
assert.Error(t, err, "старт на негодном числе прошёл молча")
})
}
}
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
owner := newOwner(t, db)
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
var login string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
var applied int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
dir := t.TempDir()
var (
mu sync.Mutex
inside int
overlap bool
)
hold := func() error {
mu.Lock()
inside++
if inside > 1 {
overlap = true
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
return nil
}
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, withMigrationLock(dir, hold))
}()
}
wg.Wait()
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
// применяется.
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
require.NoError(t, err)
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
require.Error(t, err, "отказ шага прошёл молча")
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
// состояние — то самое, из-за которого следующий запуск применил бы шаг
// второй раз и упал бы на заведённой таблице.
var version int
err = db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
if err == nil {
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
}
var tables int
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
db, _, _ := newTestDB(t)
tables := []string{
"users", "files", "topics", "audio_records",
"texts", "structures", "recognitions", "record_events",
}
seen := 0
for _, table := range tables {
rows, err := db.Reader().QueryContext(context.Background(),
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
require.NoError(t, err)
for rows.Next() {
var (
name string
columnType string
dflt any
)
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
if !isTimeColumn(name) {
continue
}
seen++
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
}
require.NoError(t, rows.Err())
closeRows(t, rows)
}
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
t.Helper()
require.NoError(t, rows.Close())
}
func isTimeColumn(name string) bool {
return strings.HasSuffix(name, "_at") || name == "delay_time"
}
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
records := NewAudioRecordRepository(db)
byService := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, records.Create(byService))
byHand := ident.New()
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
byHand, owner, entity.StateUploaded, now, now, now,
)
require.NoError(t, err)
acquired := map[string]bool{}
for range 2 {
got, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
acquired[got.ID] = true
}
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
db, _, _ := newTestDB(t)
acquire := explain(t, db, `
SELECT id FROM audio_records
WHERE state IN (?, ?)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
ORDER BY created_at, id
LIMIT 1`,
entity.StateUploaded, entity.StateNormalized, "now", "now")
list := explain(t, db, `
SELECT id FROM audio_records
WHERE owner_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 31`,
"owner", "now", "now", "id")
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
assert.NotContains(t, plan, "SCAN audio_records",
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
}
}
func explain(t *testing.T, db *DB, query string, args ...any) string {
t.Helper()
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var plan strings.Builder
for rows.Next() {
var id, parent, notUsed int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
plan.WriteString(detail)
plan.WriteString("; ")
}
require.NoError(t, rows.Err())
return plan.String()
}
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
db, err := Open(t.TempDir(), testSettings())
require.NoError(t, err)
require.NoError(t, db.Close())
assert.NoError(t, db.Close(), "повторное закрытие отказало")
}
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
_, store, dir := newTestDB(t)
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
require.Error(t, err, "отказ источника прошёл молча")
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
temporary, err := store.HasTemporary(recordID)
require.NoError(t, err)
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
sent bool
}
func (r *brokenReader) Read(p []byte) (int, error) {
if !r.sent {
r.sent = true
copy(p, strings.Repeat("a", min(len(p), 64)))
return min(len(p), 64), nil
}
return 0, errors.New("источник оборвался")
}
// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
_, store, dir := newTestDB(t)
// noPath судит вторую половину: ни каталога данных, ни временной приставки
// в цепочке отказа быть не должно.
noPath := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
}
t.Run("места на диске нет", func(t *testing.T) {
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
})
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
})
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
recordID := ident.New()
recordDir := filepath.Join(dir, recordsDir, recordID)
require.NoError(t, os.MkdirAll(recordDir, 0o750))
require.NoError(t, os.Chmod(recordDir, 0o500))
t.Cleanup(func() {
if err := os.Chmod(recordDir, 0o750); err != nil {
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
}
})
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
})
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
recordID := ident.New()
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
})
t.Run("копии нет", func(t *testing.T) {
_, err := store.Open(ident.New(), "voice.mp3")
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
})
}
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
path string
}
func (r *diskFullReader) Read([]byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
db, store, dir := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
for _, name := range []string{"original.mp3", "normalized.ogg"} {
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
require.NoError(t, err)
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
}
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
require.NoError(t, err)
assert.Len(t, records, 1, "второго места для копий записи не появляется")
}
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
require.NoError(t, err)
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
reader, err := files.Open(file.Id)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
_, err = reader.Seek(4, io.SeekStart)
require.NoError(t, err)
slice := make([]byte, 3)
_, err = io.ReadFull(reader, slice)
require.NoError(t, err)
assert.Equal(t, "456", string(slice))
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
// место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
type FileRepository struct {
db *DB
store *Store
}
func NewFileRepository(db *DB, store *Store) *FileRepository {
return &FileRepository{db: db, store: store}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
work, err := newWorkFile(filepath.Ext(file.FileName))
if err != nil {
return nil, err
}
src, err := repo.store.Open(file.RecordID, file.FileName)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer func() { _ = src.Close() }()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
//
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
// файл, которого ещё нет или который короче принятого.
//
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
//
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
func (repo *FileRepository) Create(
recordID, name string,
work contract.WorkFile,
meta contract.FileMeta,
ownerID string,
) (*entity.File, error) {
source, err := os.Open(work.Path())
if err != nil {
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
// аудио, а отказ кончается в журнале.
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
}
size, putErr := repo.store.Put(recordID, name, source)
closeErr := source.Close()
if err := errors.Join(putErr, closeErr); err != nil {
return nil, err
}
file := &entity.File{
Id: ident.New(),
RecordID: recordID,
FileName: name,
Size: size,
Format: meta.Format,
DurationMs: meta.DurationMs,
CreatedAt: clock.Now(),
}
query, args := insertSQL("files", map[string]any{
"id": file.Id,
"owner_id": ownerID,
"record_id": file.RecordID,
"file_name": file.FileName,
"size_bytes": file.Size,
"format": file.Format,
"duration_ms": file.DurationMs,
"created_at": formatTime(file.CreatedAt),
})
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
repo.store.Remove(recordID, name),
)
}
return file, nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
file := &entity.File{}
var (
createdAt string
recordID string
fileName string
size int64
format string
durationMs int64
)
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
FROM files WHERE id = ?`, id,
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("file %s is not found", id)
}
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
}
file.Id = id
file.RecordID = recordID
file.FileName = fileName
file.Size = size
file.Format = format
file.DurationMs = durationMs
file.CreatedAt = requiredTimeOf(createdAt)
return file, nil
}
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
// диапазону читает кусок, а не файл целиком.
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
return repo.store.Open(file.RecordID, file.FileName)
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// UserRepository — учётные записи сервиса.
type UserRepository struct {
db *DB
}
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
login, ok := entity.AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, contract.ErrLoginNotAcceptable
}
account, err := findUserByLogin(repo.db.Reader(), login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, nil
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Повторный поиск закрывает окно между читающим пулом и пишущей
// транзакцией: пока её ждали, запись мог завести сосед.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
name := entity.AcceptDisplayName(identity.Name)
email, _ := entity.AcceptEmail(identity.Email)
account, err = insertUser(tx, login, name, email)
switch {
case err == nil:
return account, true, commitAccount(tx, account)
case !isUniqueViolation(err):
return nil, false, fmt.Errorf("failed to create user account: %w", err)
}
// **Два отказа уникальности различаются, и исход у них разный**, а какая
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
// просто взять заведённую соседом запись.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
// этого разреза второй человек с общим адресом не завёлся бы никогда —
// повторный поиск по логину снова ничего не находит.
account, err = insertUser(tx, login, name, "")
if err != nil {
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
}
return account, true, commitAccount(tx, account)
}
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
}
return nil
}
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
id := ident.New()
now := formatTime(clock.Now())
_, err := tx.ExecContext(context.Background(),
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, login, name, email, now, now,
)
if err != nil {
return nil, err
}
return &contract.UserAccount{ID: id, Name: name}, nil
}
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
account := &contract.UserAccount{}
err := q.QueryRowContext(context.Background(),
"SELECT id, name FROM users WHERE provider_login = ?", login,
).Scan(&account.ID, &account.Name)
switch {
case err == nil:
return account, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package sqlite
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"syscall"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
)
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
// данных рядом с базой: замок принадлежит каталогу, а не машине.
const migrationLockFile = "migrate.lock"
// Migrate приводит схему к последнему шагу.
//
// # Порядок
//
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
// запрос и на каждый прогон воркера — вместо одной строки о причине их
// становятся сотни, и первопричина в них теряется.
//
// # Чем держится неделимость
//
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
//
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
// дают отказ сбора, а не молчаливый выбор одного.
//
// # Почему замок наш
//
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
// замка, который надо чистить руками, не остаётся.
//
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
provider, err := goose.NewProvider(
goose.DialectSQLite3,
db.Writer(),
nil,
goose.WithGoMigrations(migrations.All()...),
// Глобальный список библиотеки не читается: перечень шагов приходит
// доводом, и два провайдера в одном процессе за общее состояние не
// спорят.
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
return fmt.Errorf("failed to prepare schema migrations: %w", err)
}
return withMigrationLock(dataDir, func() error {
results, err := provider.Up(ctx)
if err != nil {
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
// владелец сервиса по ней находит файл шага.
return fmt.Errorf("failed to apply schema migration: %w", err)
}
for _, result := range results {
logger.Info("Schema migration applied",
"migration_version", result.Source.Version,
"duration_ms", result.Duration.Milliseconds())
}
return nil
})
}
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
//
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
func withMigrationLock(dataDir string, run func() error) error {
path := filepath.Join(dataDir, migrationLockFile)
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
if err != nil {
return fmt.Errorf("failed to open migration lock: %w", err)
}
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
// снимает ядро тем же движением — просроченного замка, который надо чистить
// руками, не остаётся.
defer func() { _ = file.Close() }()
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
}
return run()
}
@@ -0,0 +1,254 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"strconv"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608220002 заводит схему сервиса целиком.
//
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
// ним — разовое снятие инварианта «применённая миграция не переписывается»
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
// применённого своей таблицей, которой отметки прежнего каталога не годятся
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
// инвариант как всякий прежний.
//
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
// ссылается, и только потом обратные ссылки записи на её приложения.
//
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
// вставка, забывшая проставить время, при умолчании прошла бы молча.
//
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
// и вид текста были закрыты схемой, потому что панель владельца правила запись
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
// стоило бы нового шага схемы — без покупателя.
func up202608220002(ctx context.Context, tx *sql.Tx) error {
for _, statement := range initStatements() {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("failed to apply initial schema: %w", err)
}
}
return nil
}
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
// приложения ссылаются на запись, запись — на учётную запись.
func down202608220002(ctx context.Context, tx *sql.Tx) error {
tables := []string{
"record_events",
"recognitions",
"structures",
"texts",
"record_topics",
"audio_records",
"topics",
"files",
"users",
}
for _, table := range tables {
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
return fmt.Errorf("failed to drop %s: %w", table, err)
}
}
return nil
}
// initStatements — шаг по одному оператору на элемент.
//
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
// запятой, и разбиение общей строки резало бы его пополам.
func initStatements() []string {
return []string{
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
// доверенного источника, и по нему запись находится при каждом
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
// и первое обращение с чужим адресом досталось бы чужой записи.
`CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
provider_login TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
// Уникальность почты частичная: пустая почта законна и не спорит с
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
// схемой — по этому отказу заведение переходит на ветвь «запись без
// почты», а не отдаёт чужую учётную запись.
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
// через запись: файл переживает свою запись — шаг заводит его до
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
//
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
// самой записи, и обязательная связь отвергала бы первую же принятую
// запись.
`CREATE TABLE files (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
record_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
format TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_files_owner ON files (owner_id)`,
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
// человека нужен целиком перед каждым обращением к модели, а собрать его
// из наборов строк можно только перебором всех его записей.
`CREATE TABLE topics (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
// чтение очереди её не тянет.
//
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
// записи не бывает, и держит это схема, а не проверка вызывающего.
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
// записей непустые, — поэтому ничью запись отвергает та же связь.
//
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
// метаданные которой прочитать не удалось, отвергается отказом и не
// заводится вовсе. Решение владельца 2026-08-15.
`CREATE TABLE audio_records (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
title TEXT,
brief TEXT,
original_filename TEXT,
duration_ms INTEGER NOT NULL,
size_bytes INTEGER NOT NULL,
state TEXT NOT NULL,
state_entered_at TEXT NOT NULL,
halted_at TEXT,
halt_reason TEXT,
error_text TEXT,
acquisition_id TEXT,
acquire_expires_at TEXT,
delay_time TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
original_file_id TEXT REFERENCES files (id),
normalized_file_id TEXT REFERENCES files (id),
transcript_text_id TEXT,
literary_text_id TEXT,
structure_id TEXT,
recognition_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
// Индекс заводится здесь, а не потом: применённый шаг схемы не
// переписывается, и добавление индекса стоило бы отдельного шага.
`CREATE INDEX idx_audio_records_acquire
ON audio_records (state, halted_at, created_at, id)`,
// Страница списка сужается владельцем и режется полным ключом
// сортировки — парой «время заведения и ключ записи».
`CREATE INDEX idx_audio_records_owner_page
ON audio_records (owner_id, created_at, id)`,
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
// набора строк в колонке нет ни связи, ни потолка.
`CREATE TABLE record_topics (
record_id TEXT NOT NULL REFERENCES audio_records (id),
topic_id TEXT NOT NULL REFERENCES topics (id),
PRIMARY KEY (record_id, topic_id)
)`,
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
// Потолок числа тем держит схема: без него часовой разговор даёт два
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
// то же самое, которое сервис объявляет приложению.
`CREATE TRIGGER trg_record_topics_limit
BEFORE INSERT ON record_topics
BEGIN
SELECT RAISE(ABORT, 'record has too many topics')
WHERE (
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
END`,
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
`CREATE TABLE texts (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
kind TEXT NOT NULL,
contents TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
// ответа изменится раньше, чем архив пересчитают.
`CREATE TABLE structures (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
version INTEGER NOT NULL,
contents TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
// Попытка распознавания у внешнего провайдера.
//
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
// здесь стоит только его имя: шаг опроса читает эту строку раз в
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
// ехал бы в память при каждом опросе.
`CREATE TABLE recognitions (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
provider TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
source_uri TEXT NOT NULL DEFAULT '',
payload_file TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
// секрете, и две колонки с этим именем сделали бы инвариант
// двусмысленным.
`CREATE TABLE record_events (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
origin TEXT NOT NULL,
step TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
outcome_text TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
}
}
@@ -0,0 +1,36 @@
// Package migrations — шаги схемы базы.
//
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
//
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог.
//
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
// заводится в точке входа и получает этот перечень доводом, поэтому два
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
// состояние.
package migrations
import (
"github.com/pressly/goose/v3"
)
// All — шаги схемы в порядке версий.
//
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
// накатывается вовсе.
func All() []*goose.Migration {
return []*goose.Migration{
goose.NewGoMigration(
202608220002,
&goose.GoFunc{RunTx: up202608220002},
&goose.GoFunc{RunTx: down202608220002},
),
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"errors"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
// провайдера. Имя задаёт сервис, как и у копий аудио.
const payloadSuffix = ".payload"
type RecognitionRepository struct {
db *DB
store *Store
}
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
return &RecognitionRepository{db: db, store: store}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
started := clock.Now()
if r.Id == "" {
r.Id = ident.New()
}
now := formatTime(started)
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO recognitions
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
)
if err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
_, err := repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
sourceURI, externalID, formatTime(clock.Now()), id,
)
if err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
// записи** и отмечает завершение попытки.
//
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
// опросе. Хранится он потому, что результат операции у провайдера не
// переспрашивается.
//
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
// сохранённое без возврата.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
attempt, err := repo.GetByID(id)
if err != nil {
return err
}
name := id + payloadSuffix
if len(raw) > 0 {
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
// её идентификатором.
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
}
}
finished := formatTime(clock.Now())
if len(raw) > 0 {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
name, finished, finished, id,
)
} else {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
finished, finished, id,
)
}
if err != nil {
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
attempt := &entity.Recognition{Id: id}
var startedAt, finishedAt, payloadFile nullString
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
FROM recognitions WHERE id = ?`, id,
).Scan(
&attempt.RecordID, &attempt.Provider, &attempt.Model,
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
&startedAt, &finishedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
attempt.StartedAt = timeOf(startedAt.NullString)
attempt.FinishedAt = timeOf(finishedAt.NullString)
return attempt, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
attempt, err := repo.GetByID(id)
if err != nil {
return nil, err
}
var payloadFile string
if err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT payload_file FROM recognitions WHERE id = ?", id,
).Scan(&payloadFile); err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
if payloadFile == "" {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
file, err := repo.store.Open(attempt.RecordID, payloadFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
raw, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
}
return raw, nil
}
@@ -0,0 +1,62 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
// значение, читается в неё, а домену отдаётся указателем.
type nullString struct {
sql.NullString
}
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
// приходит целиком байтами, а укладка принимает поток.
func bytesReader(raw []byte) io.Reader {
return bytes.NewReader(raw)
}
type RecordEventRepository struct {
db *DB
}
func NewRecordEventRepository(db *DB) *RecordEventRepository {
return &RecordEventRepository{db: db}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
if event.Id == "" {
event.Id = ident.New()
}
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO record_events
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.Id, event.RecordID, event.Origin, event.Step,
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
)
if err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
return nil
}
+260
View File
@@ -0,0 +1,260 @@
package sqlite
import (
"context"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
const defaultListLimit = 30
// List отдаёт страницу записей владельца, новыми сверху.
//
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
// элемент между ними не пришёл бы никогда — и оба раза молча.
//
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
// определён вовсе.
//
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
// колонками самой записи.
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
// Пустой владелец не совпадает ни с одной записью. Правило записано со
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
// пустую строку пропустила бы.
if q.OwnerID == "" {
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
}
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
// воркеров нет вовсе.
if q.Limit <= 0 {
q.Limit = defaultListLimit
}
conditions := []string{"owner_id = ?"}
args := []any{q.OwnerID}
state, stateArgs, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != "" {
conditions = append(conditions, state)
args = append(args, stateArgs...)
}
total, err := repo.countRecords(conditions, args)
if err != nil {
return nil, err
}
pageConditions := conditions
pageArgs := args
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору.
if q.Cursor != nil {
pageConditions = append(append([]string{}, conditions...),
"(created_at < ? OR (created_at = ? AND id < ?))")
cursorTime := formatTime(q.Cursor.CreatedAt)
pageArgs = append(append([]any{}, args...),
cursorTime, cursorTime, q.Cursor.ID)
}
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться.
query := "SELECT " + columns + " FROM " + recordsTable +
" WHERE " + strings.Join(pageConditions, " AND ") +
" ORDER BY created_at DESC, id DESC LIMIT ?"
pageArgs = append(pageArgs, q.Limit+1)
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err)
}
defer func() { _ = rows.Close() }()
items := []*entity.AudioRecord{}
for rows.Next() {
if err := rows.Scan(targets...); err != nil {
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
}
items = append(items, rowToAudioRecord(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
}
page := &contract.RecordPage{TotalItems: total}
if len(items) > q.Limit {
last := items[q.Limit-1]
page.NextCursor = &contract.RecordCursor{
CreatedAt: last.CreatedAt,
ID: last.Id,
}
items = items[:q.Limit]
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.Id)
}
topics, err := repo.topicsOf(ids)
if err != nil {
return nil, err
}
for _, item := range items {
item.TopicIDs = topics[item.Id]
}
page.Items = items
return page, nil
}
// stateCondition переводит состояние отбора в условие запроса.
//
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
if filter == nil {
return "", nil, nil
}
switch *filter {
case entity.ListFilterHalted:
return "halted_at IS NOT NULL", nil, nil
case entity.ListFilterWorking:
condition, args := stateIn(entity.WorkingStages())
return "halted_at IS NULL AND " + condition, args, nil
case entity.ListFilterDone:
condition, args := stateIn(entity.TerminalStages())
return "halted_at IS NULL AND " + condition, args, nil
}
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем.
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}
func stateIn(stages []entity.Stage) (string, []any) {
names := entity.StageNames(stages)
args := make([]any, 0, len(names))
placeholders := make([]string, 0, len(names))
for _, name := range names {
args = append(args, name)
placeholders = append(placeholders, "?")
}
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
}
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
var total int
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err)
}
return total, nil
}
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
// иначе стоила бы сотни обращений к базе.
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
out := map[string][]string{}
if len(recordIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(recordIDs))
args := make([]any, 0, len(recordIDs))
for _, id := range recordIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var recordID, topicID string
if err := rows.Scan(&recordID, &topicID); err != nil {
return nil, fmt.Errorf("failed to read a record topic: %w", err)
}
out[recordID] = append(out[recordID], topicID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
return out, nil
}
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
//
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
// чужой темы, как только темы начнёт писать языковая модель.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{}
if len(ids) == 0 || ownerID == "" {
return out, nil
}
placeholders := make([]string, 0, len(ids))
args := []any{ownerID}
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
strings.Join(placeholders, ", ") + ")"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, fmt.Errorf("failed to read a topic: %w", err)
}
out[id] = name
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
return out, nil
}
@@ -0,0 +1,173 @@
package sqlite
import (
"database/sql"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
//
// Работает оно **по имени колонки**: именованные параметры запроса и место
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
// подмена значения.
//
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
// можно забыть в отображении или в шаге схемы, и сверку держат правила
// `internal/archrules`.
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
// отправителя, длительность, размер и темы не трогаются вовсе.
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
return map[string]any{
"state": r.State,
"state_entered_at": formatTime(r.StateEnteredAt),
"halted_at": timeValue(r.HaltedAt),
"halt_reason": stringValue(r.HaltReason),
"error_text": stringValue(r.ErrorText),
"acquisition_id": stringValue(r.AcquisitionID),
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
"delay_time": timeValue(r.DelayTime),
"attempts": r.Attempts,
"original_file_id": stringValue(r.OriginalFileID),
"normalized_file_id": stringValue(r.NormalizedFileID),
"transcript_text_id": stringValue(r.TranscriptTextID),
"literary_text_id": stringValue(r.LiteraryTextID),
"structure_id": stringValue(r.StructureID),
"recognition_id": stringValue(r.RecognitionID),
"updated_at": formatTime(r.UpdatedAt),
}
}
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
//
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
// молча.
func writeRecord(r *entity.AudioRecord) map[string]any {
values := writeOwnedByPipeline(r)
own := map[string]any{
"id": r.Id,
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
// шага, записанный поверх, стёр бы его молча.
"owner_id": r.OwnerID,
"title": stringValue(r.Title),
"brief": stringValue(r.Brief),
// Имя файла отправителя, длительность и размер кладёт приём и только он:
// это снимок принятого, и конвейер его не пересчитывает.
"original_filename": stringValue(r.OriginalFilename),
"duration_ms": numberValue(r.DurationMs),
"size_bytes": numberValue(r.SizeBytes),
"created_at": formatTime(r.CreatedAt),
}
for name, value := range own {
values[name] = value
}
return values
}
// recordRow — сырые значения одной строки аудиозаписи.
type recordRow struct {
id string
ownerID string
title sql.NullString
brief sql.NullString
originalFilename sql.NullString
durationMs int64
sizeBytes int64
state string
stateEnteredAt string
haltedAt sql.NullString
haltReason sql.NullString
errorText sql.NullString
acquisitionID sql.NullString
acquireExpiresAt sql.NullString
delayTime sql.NullString
attempts int
originalFileID sql.NullString
normalizedFileID sql.NullString
transcriptTextID sql.NullString
literaryTextID sql.NullString
structureID sql.NullString
recognitionID sql.NullString
createdAt string
updatedAt string
}
// readRecordColumns — куда кладётся каждая колонка при чтении.
//
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
func readRecordColumns(row *recordRow) map[string]any {
return map[string]any{
"id": &row.id,
"owner_id": &row.ownerID,
"title": &row.title,
"brief": &row.brief,
"original_filename": &row.originalFilename,
"duration_ms": &row.durationMs,
"size_bytes": &row.sizeBytes,
"state": &row.state,
"state_entered_at": &row.stateEnteredAt,
"halted_at": &row.haltedAt,
"halt_reason": &row.haltReason,
"error_text": &row.errorText,
"acquisition_id": &row.acquisitionID,
"acquire_expires_at": &row.acquireExpiresAt,
"delay_time": &row.delayTime,
"attempts": &row.attempts,
"original_file_id": &row.originalFileID,
"normalized_file_id": &row.normalizedFileID,
"transcript_text_id": &row.transcriptTextID,
"literary_text_id": &row.literaryTextID,
"structure_id": &row.structureID,
"recognition_id": &row.recognitionID,
"created_at": &row.createdAt,
"updated_at": &row.updatedAt,
}
}
// rowToAudioRecord собирает доменную запись из прочитанной строки.
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
return &entity.AudioRecord{
Id: row.id,
OwnerID: row.ownerID,
Title: stringOf(row.title),
Brief: stringOf(row.brief),
OriginalFilename: stringOf(row.originalFilename),
DurationMs: numberOf(row.durationMs),
SizeBytes: numberOf(row.sizeBytes),
State: row.state,
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
HaltedAt: timeOf(row.haltedAt),
HaltReason: stringOf(row.haltReason),
ErrorText: stringOf(row.errorText),
AcquisitionID: stringOf(row.acquisitionID),
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
DelayTime: timeOf(row.delayTime),
Attempts: row.attempts,
OriginalFileID: stringOf(row.originalFileID),
NormalizedFileID: stringOf(row.normalizedFileID),
TranscriptTextID: stringOf(row.transcriptTextID),
LiteraryTextID: stringOf(row.literaryTextID),
StructureID: stringOf(row.structureID),
RecognitionID: stringOf(row.recognitionID),
TopicIDs: []string{},
CreatedAt: requiredTimeOf(row.createdAt),
UpdatedAt: requiredTimeOf(row.updatedAt),
}
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsTable — таблица аудиозаписей.
const recordsTable = "audio_records"
type AudioRecordRepository struct {
db *DB
}
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
return &AudioRecordRepository{db: db}
}
// Create заводит аудиозапись.
//
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
// идентификатором. Пустой заполняется единой точкой выдачи.
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
if r.Id == "" {
r.Id = ident.New()
}
now := clock.Now()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
if r.StateEnteredAt.IsZero() {
r.StateEnteredAt = now
}
query, args := insertSQL(recordsTable, writeRecord(r))
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
return nil
}
// Save сохраняет запись, захват которой держит holder.
//
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
// именно значение, а не занятость записи — захват, перевыданный другому по
// протуханию срока или после того, как человек вернул запись в работу, обязан
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
//
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
// получают признак захвата от FindAndAcquire.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
r.UpdatedAt = clock.Now()
where := "id = :id"
whereArgs := []any{sql.Named("id", r.Id)}
if holder != "" {
where += " AND acquisition_id = :holder"
whereArgs = append(whereArgs, sql.Named("holder", holder))
}
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
if err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
}
if affected > 0 {
return nil
}
// Строк не тронуто по одной из двух причин, и различить их можно только
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
if _, err := repo.Get(r.Id); err != nil {
return err
}
return &contract.LostAcquisitionError{JobID: r.Id}
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
// иначе перебирается список заведённых записей, а идентификатор записи и есть
// то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
// учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
if err != nil {
return nil, err
}
return record, nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
return repo.read("id = ?", id)
}
// read читает одну запись по условию.
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
// настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
record := rowToAudioRecord(row)
topics, err := repo.topicsOf([]string{record.Id})
if err != nil {
return nil, err
}
record.TopicIDs = topics[record.Id]
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
// возвратом.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
now := clock.Now()
holder := ident.New()
args := []any{
sql.Named("holder", holder),
sql.Named("now", formatTime(now)),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
states := make([]string, 0, len(stages))
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
args = append(args,
sql.Named(stateKey, stage.Name),
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
)
states = append(states, ":"+stateKey)
}
expiry.WriteString(" END")
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
// Сравнения по неуникальному значению для этого мало — порядок обработки
// стал бы невоспроизводимым.
query := `
UPDATE ` + recordsTable + `
SET acquisition_id = :holder,
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated_at = :now
WHERE id = (
SELECT id FROM ` + recordsTable + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < :now)
AND (acquisition_id IS NULL
OR acquire_expires_at IS NULL
OR acquire_expires_at < :now)
ORDER BY created_at, id
LIMIT 1
)
RETURNING id`
var id string
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
}
+626
View File
@@ -0,0 +1,626 @@
package sqlite
import (
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// newWorkingRecord заводит запись, пригодную к захвату.
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, NewAudioRecordRepository(db).Create(record))
return record
}
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
// связью с учётной записью и пустого значения не принимает.
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
db, _, _ := newTestDB(t)
records := NewAudioRecordRepository(db)
t.Run("пустой владелец", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с пустым владельцем сохранилась")
})
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), OwnerID: ident.New(),
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
})
t.Run("запросом к базе тоже", func(t *testing.T) {
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, '', 0, 0, ?, ?, ?, ?)`,
ident.New(), entity.StateUploaded, now, now, now,
)
assert.Error(t, err, "ничья запись завелась запросом к базе")
})
}
// Файл без владельца не сохраняется — тем же правилом схемы.
func TestFileWithoutOwnerIsRejected(t *testing.T) {
db, store, _ := newTestDB(t)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
assert.Error(t, err, "файл с пустым владельцем сохранился")
}
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
// запрет держит схема обязательной связью, а не проверка вызывающего.
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
db, store, _ := newTestDB(t)
t.Run("с аудиозаписями", func(t *testing.T) {
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
})
t.Run("с одними файлами", func(t *testing.T) {
owner := newOwner(t, db)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с файлами удалилась")
})
t.Run("с одними темами", func(t *testing.T) {
owner := newOwner(t, db)
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
ident.New(), owner, "тема", now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с темами удалилась")
})
t.Run("пустая удаляется", func(t *testing.T) {
owner := newOwner(t, db)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
})
}
// У записи не больше пяти тем, и держит это схема.
func TestRecordTopicsAreCapped(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
now := clock.Now().Format(timeLayout)
for i := range entity.MaxTopicsPerRecord + 1 {
topicID := ident.New()
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
topicID, owner, "тема-"+topicID, now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
)
if i < entity.MaxTopicsPerRecord {
require.NoError(t, err, "тема %d не назначилась", i)
continue
}
assert.Error(t, err, "шестая тема назначилась")
}
}
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
//
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
// одному, остальные получают признак «работы сейчас нет».
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
const racers = 8
var (
mu sync.Mutex
acquired []*contract.AcquiredRecord
empty int
)
start := make(chan struct{})
var wg sync.WaitGroup
for range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := records.FindAndAcquire(entity.WorkingStages())
mu.Lock()
defer mu.Unlock()
var missing *contract.JobNotFoundError
switch {
case err == nil:
acquired = append(acquired, got)
case assert.ErrorAs(t, err, &missing):
empty++
}
}()
}
close(start)
wg.Wait()
require.Len(t, acquired, 1, "запись досталась не одному захвату")
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
assert.Equal(t, record.Id, acquired[0].ID)
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
}
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
// признака, а не занятостью записи.
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
first, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
// Захват уходит другому: срок протухает, и запись достаётся следующему.
_, err = db.Writer().ExecContext(context.Background(),
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
)
require.NoError(t, err)
second, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
// Прежний держатель пишет свой результат — и не пишет.
stale, err := records.Get(record.Id)
require.NoError(t, err)
stale.MoveToState(entity.StateNormalized)
err = records.Save(stale, first.Holder)
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
}
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
// которыми распоряжается сам.
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
held, err := records.Get(record.Id)
require.NoError(t, err)
// Владелец за это время правит поле, которого шаг не касается.
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
require.NoError(t, err)
held.MoveToState(entity.StateNormalized)
require.NoError(t, records.Save(held, acquired.Holder))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
require.NotNil(t, after.Title)
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
}
// Составная операция, которая читает и следом пишет, идёт по пишущему
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
}()
}
close(start)
wg.Wait()
var count int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
}
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
// стёрла бы расшифровку живого человека без следа.
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
require.NoError(t, err)
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
assert.Equal(t, stored.Id, again.Id, "строка та же")
read, err := texts.GetByID(stored.Id)
require.NoError(t, err)
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
structures := NewStructureRepository(db)
first, err := structures.Put(record.Id, entity.StructureVersion,
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
require.NoError(t, err)
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
require.NoError(t, err)
structure, err := structures.GetByID(first.Id)
require.NoError(t, err)
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
require.NoError(t, recognitions.Finish(attempt.Id, nil))
raw, err := recognitions.ReadRaw(attempt.Id)
require.NoError(t, err)
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
}
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
// опроса читает строку попытки без него.
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
// Строка попытки читается без ответа: он не колонка.
read, err := recognitions.GetByID(attempt.Id)
require.NoError(t, err)
require.NotNil(t, read.FinishedAt)
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
require.NoError(t, err)
require.NoError(t, file.Close())
}
// Два одновременных первых обращения одним значением дают ровно одну учётную
// запись: уникальность держит схема, а не порядок обращений.
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
var (
mu sync.Mutex
ids = map[string]bool{}
)
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
account, _, err := users.EnsureUser(contract.Identity{Login: login})
if assert.NoError(t, err) {
mu.Lock()
ids[account.ID] = true
mu.Unlock()
}
}()
}
close(start)
wg.Wait()
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
var rows int
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
}
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
require.NoError(t, err)
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
require.NoError(t, err)
require.True(t, created)
assert.NotEqual(t, first.ID, second.ID)
var email string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
}
// Найденную запись повторное обращение не переписывает: иначе правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
require.NoError(t, err)
require.True(t, created)
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
require.NoError(t, err)
assert.False(t, created)
assert.Equal(t, first.ID, again.ID)
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
}
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
// и занятый писатель его не держит.
//
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
// очередь к соединению ожиданием занятой базы не ограничена.
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
first, created, err := users.EnsureUser(contract.Identity{Login: login})
require.NoError(t, err)
require.True(t, created)
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
// поэтому пока она держится, второй транзакции не начаться.
tx, err := db.Writer().BeginTx(context.Background(), nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
type answer struct {
account *contract.UserAccount
created bool
err error
took time.Duration
}
done := make(chan answer, 1)
go func() {
started := time.Now()
account, created, err := users.EnsureUser(contract.Identity{Login: login})
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
}()
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
const limit = time.Second
select {
case got := <-done:
require.NoError(t, got.err)
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
assert.Equal(t, first.ID, got.account.ID)
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
case <-time.After(limit):
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
}
}
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
// там, где никого не назвал.
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
for name, login := range map[string]string{
"пустой": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
} {
t.Run(name, func(t *testing.T) {
_, _, err := users.EnsureUser(contract.Identity{Login: login})
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
})
}
var rows int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
}
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
//
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
// держит запись занятой до протухания срока, оставленное время входа в рубеж
// останавливает её снова первым же захватом, а оставленные отказы и пауза
// откладывают первый прогон на накопленный срок.
func TestResumeReleasesEveryGuard(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
events := NewRecordEventRepository(db)
// Так выглядит запись, остановленная после долгих отказов: захват на ней
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
// рубеже она простояла дольше предела.
_, err := db.Writer().ExecContext(context.Background(), `
UPDATE audio_records
SET halted_at = ?, halt_reason = ?, error_text = ?,
acquisition_id = ?, acquire_expires_at = ?,
delay_time = ?, attempts = 7, state_entered_at = ?
WHERE id = ?`,
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
clock.Now().Add(time.Hour).Format(timeLayout),
clock.Now().Add(-48*time.Hour).Format(timeLayout),
record.Id,
)
require.NoError(t, err)
// Остановленная запись захвату не выдаётся.
_, err = records.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
halted, err := records.Get(record.Id)
require.NoError(t, err)
halted.Resume()
require.NoError(t, records.Save(halted, ""))
require.NoError(t, events.Append(&entity.RecordEvent{
RecordID: record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.False(t, after.IsHalted(), "признак остановки остался")
assert.Nil(t, after.HaltReason, "причина остановки осталась")
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
assert.Equal(t, record.Id, acquired.ID)
// И возврат виден в журнале событий записи — происхождением «человек».
var origin, outcome string
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
).Scan(&origin, &outcome))
assert.Equal(t, entity.EventOriginHuman, origin)
assert.Equal(t, entity.EventOutcomeResumed, outcome)
}
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
// строки о готовности схема приведена целиком.
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
dir := t.TempDir()
journal := &strings.Builder{}
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, logger))
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
// первым приёмом нет.
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
}
+170
View File
@@ -0,0 +1,170 @@
package sqlite
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
const recordsDir = "records"
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
// имена из обычного перечисления каталога, а сама приставка отличает
// незавершённую укладку от рабочего имени.
const tempPrefix = ".partial-"
// Store — файлы записей в каталоге данных.
//
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
// запись убирается целиком одним движением — плоский каталог, где копии
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
//
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
// файла, ни именем каталога.
type Store struct {
root string
}
// NewStore заводит раздел записей в каталоге данных.
func NewStore(dataDir string) *Store {
return &Store{root: filepath.Join(dataDir, recordsDir)}
}
// dir — подкаталог одной записи.
func (s *Store) dir(recordID string) string {
return filepath.Join(s.root, recordID)
}
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
// в ответ, ни в метку метрики.
func (s *Store) path(recordID, name string) string {
return filepath.Join(s.dir(recordID), name)
}
// Put кладёт содержимое под рабочим именем **атомарно**.
//
// Содержимое пишется во временное имя в том же подкаталоге записи и
// переименовывается в рабочее только после того, как поток дочитан до конца без
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
// его пределах не копирует содержимое и не может оборваться на середине.
//
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
// результат — атомарная укладка единственное, что этого не допускает.
//
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
// имени не остаётся, рабочего имени не появляется.
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
}
temp := s.path(recordID, tempPrefix+ident.New())
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
}
size, copyErr := io.Copy(file, src)
syncErr := file.Sync()
closeErr := file.Close()
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
_ = os.Remove(temp)
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
// но по причине владелец различает исчерпание места, отсутствие прав и
// файловую систему только для чтения.
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
}
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
_ = os.Remove(temp)
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
}
return size, nil
}
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
// по диапазону читает кусок, а не файл целиком.
func (s *Store) Open(recordID, name string) (*os.File, error) {
file, err := os.Open(s.path(recordID, name))
if err != nil {
// Отказ называет запись её идентификатором и не несёт имени файла:
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
// нет» и «прав нет» ведут владельца к разным действиям.
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
}
return file, nil
}
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
// там, где укладка до него не дошла.
func (s *Store) Remove(recordID, name string) error {
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
}
return nil
}
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
// отсутствию рабочего имени.
func (s *Store) HasTemporary(recordID string) (bool, error) {
entries, err := os.ReadDir(s.dir(recordID))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
}
for _, entry := range entries {
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
return true, nil
}
}
return false, nil
}
// causeOf снимает с отказа файловой операции путь, оставляя причину.
//
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
// только для чтения — три поломки, требующие трёх разных действий, а отказ
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
// строку потом не убрать.
//
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
// обёрткой и от её снятия не страдает.
//
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
// сброса и закрытия, и путь лежит в каждой из них.
func causeOf(err error) error {
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
case *os.PathError:
return typed.Err
case *os.LinkError:
return typed.Err
case interface{ Unwrap() []error }:
parts := typed.Unwrap()
causes := make([]error, 0, len(parts))
for _, part := range parts {
causes = append(causes, causeOf(part))
}
return errors.Join(causes...)
}
return err
}
+205
View File
@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
type TextRepository struct {
db *DB
}
func NewTextRepository(db *DB) *TextRepository {
return &TextRepository{db: db}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
//
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
// вторую строку на гонке двух шагов.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && existing != "" {
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// драйвера несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, kind, contents, now, now,
); err != nil {
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
default:
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
// вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
}
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
text := &entity.Text{Id: id}
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
).Scan(&text.RecordID, &text.Kind, &text.Contents)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return text, nil
}
type StructureRepository struct {
db *DB
}
func NewStructureRepository(db *DB) *StructureRepository {
return &StructureRepository{db: db}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
// перечень реплик поверх непустого не кладётся.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
if replicas == nil {
replicas = []entity.Replica{}
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
if len(replicas) == 0 && len(existing) > len("[]") {
stored, decodeErr := decodeReplicas(id, existing)
if decodeErr != nil {
return nil, decodeErr
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, version, string(contents), now, now,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
structure := &entity.Structure{Id: id}
var contents string
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
).Scan(&structure.RecordID, &structure.Version, &contents)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
replicas, err := decodeReplicas(id, contents)
if err != nil {
return nil, err
}
structure.Replicas = replicas
return structure, nil
}
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
if contents == "" {
return nil, nil
}
var replicas []entity.Replica
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
return replicas, nil
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
sqlitedriver "modernc.org/sqlite"
sqlitelib "modernc.org/sqlite/lib"
)
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
// секундная точность.
//
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
// совпадает с хронологией, и отбор по колонке времени работает без разбора
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
// не выдавалась бы ни одному воркеру никогда.
const timeLayout = "2006-01-02T15:04:05Z"
// formatTime приводит метку времени к виду колонки.
func formatTime(v time.Time) string {
return v.UTC().Format(timeLayout)
}
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
// и отсутствующее — одно и то же: «времени нет».
func timeValue(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return formatTime(*v)
}
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
func timeOf(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
parsed, err := time.Parse(timeLayout, v.String)
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
// requiredTimeOf читает обязательную колонку времени.
func requiredTimeOf(v string) time.Time {
parsed, err := time.Parse(timeLayout, v)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
// же.
func stringValue(v *string) any {
if v == nil || *v == "" {
return nil
}
return *v
}
// stringOf читает необязательную строку.
func stringOf(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
out := v.String
return &out
}
// numberOf читает необязательное число.
//
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
// отсутствие.
func numberOf(v int64) *int64 {
out := v
return &out
}
// numberValue кладёт необязательное число нулём: колонка обязательна.
func numberValue(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// insertSQL собирает вставку из карты «колонка → значение».
//
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
// имени такого сдвига не существует вовсе.
//
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
// журналу читала бы каждый раз новый запрос.
func insertSQL(table string, values map[string]any) (string, []any) {
names := sortedNames(values)
placeholders := make([]string, 0, len(names))
args := make([]any, 0, len(names))
for _, name := range names {
placeholders = append(placeholders, ":"+name)
args = append(args, sql.Named(name, values[name]))
}
query := fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(names, ", "),
strings.Join(placeholders, ", "),
)
return query, args
}
// updateSQL собирает правку из карты «колонка → значение» и условия.
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
names := sortedNames(values)
assignments := make([]string, 0, len(names))
args := make([]any, 0, len(names)+len(whereArgs))
for _, name := range names {
assignments = append(assignments, name+" = :"+name)
args = append(args, sql.Named(name, values[name]))
}
args = append(args, whereArgs...)
query := fmt.Sprintf(
"UPDATE %s SET %s WHERE %s",
table,
strings.Join(assignments, ", "),
where,
)
return query, args
}
func sortedNames(values map[string]any) []string {
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
return names
}
// selectList собирает перечень колонок для выборки из той же карты, по которой
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
func selectList(targets map[string]any, prefix string) (string, []any) {
names := sortedNames(targets)
columns := make([]string, 0, len(names))
scan := make([]any, 0, len(names))
for _, name := range names {
columns = append(columns, prefix+name)
scan = append(scan, targets[name])
}
return strings.Join(columns, ", "), scan
}
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
//
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
func isUniqueViolation(err error) bool {
var sqliteErr *sqlitedriver.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
}
-27
View File
@@ -1,27 +0,0 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
// `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
-37
View File
@@ -1,37 +0,0 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
-134
View File
@@ -1,134 +0,0 @@
package telegram
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
// недоступности Telegram, у которой исход обратный — подъём без бота.
//
// Отказ от входа Telegram этим значением больше не выражается: намерение
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
// поэтому сюда он доходит только в обход проверки.
var ErrEmptyToken = errors.New("telegram bot token is empty")
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
//
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
return newBot(token, tgbotapi.APIEndpoint, logger)
}
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
if token == "" {
return nil, ErrEmptyToken
}
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
}
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
// проба здоровья не отвечает, а в журнале ни строки.
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
// управляющим символом или неверной `%`-последовательностью иначе уезжает
// в журнал целиком: перенос строки в конце значения ловится так же.
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
if err != nil {
return nil, WithoutURL(err)
}
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
bot.Client = &safeClient{inner: &http.Client{}}
return bot, nil
}
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
// решением, а не замером: одно обращение за `getMe` укладывается в доли
// секунды, а десять секунд — потолок, после которого Telegram считается
// недоступным и сервис поднимается без него.
const ProbeTimeout = 10 * time.Second
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
type safeClient struct {
inner *http.Client
}
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
resp, err := c.inner.Do(req)
if err != nil {
return nil, WithoutURL(err)
}
return resp, nil
}
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
func WithoutURL(err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err
}
return err
}
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
type redactingLogger struct {
token string
logger *slog.Logger
}
const redactedToken = "«токен»"
func (l *redactingLogger) Println(v ...any) {
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
}
func (l *redactingLogger) Printf(format string, v ...any) {
l.write(fmt.Sprintf(format, v...))
}
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
// отладку, если её включить, а разделить их она не даёт.
func (l *redactingLogger) write(message string) {
l.logger.Warn("Telegram library log",
"message", strings.ReplaceAll(message, l.token, redactedToken))
}
-172
View File
@@ -1,172 +0,0 @@
package telegram
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.NoError(t, err)
return bot, server
}
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
// api.telegram.org.
server.Close()
t.Run("getFile", func(t *testing.T) {
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
t.Run("sendMessage", func(t *testing.T) {
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
}
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
}
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
func TestLibraryLoggerRedactsToken(t *testing.T) {
journal := &bytes.Buffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
redacting := &redactingLogger{token: probeToken, logger: logger}
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
written := journal.String()
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
}
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken)
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
require.NotErrorIs(t, err, ErrEmptyToken)
}
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
// работать, иначе чистка стоила бы узнаваемости отказа.
func TestWithoutURLKeepsCause(t *testing.T) {
cause := errors.New("dial tcp: connection refused")
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
cleaned := WithoutURL(wrapped)
assert.NotContains(t, cleaned.Error(), probeToken)
require.ErrorIs(t, cleaned, cause)
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
}
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
// косвенный по необходимости — библиотека не отдаёт установленный логгер
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
// оказаться в нашем журнале.
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
journal := &bytes.Buffer{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
}))
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
require.NoError(t, err)
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
// чтобы строка появилась без обрыва сети.
bot.Debug = true
_, err = bot.GetMe()
require.NoError(t, err)
assert.Contains(t, journal.String(), "Telegram library log",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}
-66
View File
@@ -1,66 +0,0 @@
package telegram
import (
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
TextLengthLimit = 4000
)
type TelegramMessageSender struct {
bot *tgbotapi.BotAPI
logger *slog.Logger
}
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// его строили здесь и там порознь, два пути одного старта разошлись в том,
// терпеть ли негодный токен, и согласовывать их приходилось руками.
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return &TelegramMessageSender{
bot: bot,
logger: logger,
}
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
// If message is short enough, send it directly
if len([]rune(text)) <= TextLengthLimit {
return s.sendSingleMessage(text, chatId, replyToMessageId)
}
// Split long message into parts
parts := s.splitMessageByWords(text, TextLengthLimit)
// Send each part
for i, part := range parts {
var replyId *int
// Only use replyToMessageId for the first part
if i == 0 {
replyId = replyToMessageId
}
err := s.sendSingleMessage(part, chatId, replyId)
if err != nil {
return err
}
}
return nil
}
// sendSingleMessage sends a single message
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
resultMsg := tgbotapi.NewMessage(chatId, text)
if replyToMessageId != nil {
resultMsg.ReplyToMessageID = *replyToMessageId
}
_, err := s.bot.Send(resultMsg)
if err != nil {
s.logger.Error("Failed to send message to tg bot", "error", err)
return err
}
return nil
}
-62
View File
@@ -1,62 +0,0 @@
package telegram
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
// splitting by words to avoid cutting words in the middle
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
var parts []string
// If text is already short enough, return as is
if len([]rune(text)) <= maxLen {
return []string{text}
}
runes := []rune(text)
for len(runes) > 0 {
// Determine the end position for this part
end := len(runes)
if end > maxLen {
end = maxLen
}
// Try to find a good split point (word boundary)
splitPoint := end
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
// Check if this is a good split point (after a space)
if runes[i] == ' ' {
splitPoint = i + 1 // Include the space in the previous part
break
}
}
// If we couldn't find a good split point, just split at maxLen
if splitPoint == end && end == maxLen {
// Check if we're in the middle of a word
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
// Try to find a split point going forward
for i := end; i < len(runes) && i < end+20; i++ {
if runes[i] == ' ' {
splitPoint = i
break
}
}
}
}
// If still no good split point, use the original end
if splitPoint > len(runes) {
splitPoint = len(runes)
}
// Add this part
parts = append(parts, string(runes[:splitPoint]))
// Move to the next part
if splitPoint >= len(runes) {
break
}
runes = runes[splitPoint:]
}
return parts
}
-125
View File
@@ -1,125 +0,0 @@
package telegram
import (
"testing"
)
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
sender := &TelegramMessageSender{}
tests := []struct {
name string
text string
maxLen int
expected []string
}{
{
name: "Short text should return as is",
text: "Привет мир",
maxLen: 25,
expected: []string{
"Привет мир",
},
},
{
name: "Text exactly at limit",
text: "Это тестовый текст который ровно соответствует лимиту",
maxLen: 35,
expected: []string{
"Это тестовый текст который ровно ",
"соответствует ",
"лимиту",
},
},
{
name: "Text with word boundaries",
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
maxLen: 25,
expected: []string{
"Это очень длинный текст ",
"для проверки работы ",
"функции разделения ",
"сообщения",
},
},
{
name: "Text with long words",
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
maxLen: 20,
expected: []string{
"Этот текст содержит ",
"оченьдлинноеслово ",
"которое не должно ",
"быть ",
"разбито",
},
},
{
name: "Text with multiple spaces",
text: "Этот текст имеет много пробелов",
maxLen: 20,
expected: []string{
"Этот текст ",
"имеет много ",
"пробелов",
},
},
{
name: "Text with Russian characters and punctuation",
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
maxLen: 25,
expected: []string{
"Привет! Как дела? Это ",
"тестовая строка для ",
"проверки работы ",
"функции.",
},
},
{
name: "Single word longer than maxLen",
text: "Некотороедлинноеслово",
maxLen: 10,
expected: []string{
"Некотороед",
"линноеслов",
"о",
},
},
{
name: "Text with mixed Russian and English",
text: "Привет Hello мир World текст для проверки",
maxLen: 20,
expected: []string{
"Привет Hello мир ",
"World текст для ",
"проверки",
},
},
{
name: "Text with special characters",
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
maxLen: 25,
expected: []string{
"Тест с символами: ",
"@#$%^&*()_+-=[]{}|;':\",./",
"<>?",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sender.splitMessageByWords(tt.text, tt.maxLen)
if len(result) != len(tt.expected) {
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
return
}
for i, expectedPart := range tt.expected {
if result[i] != expectedPart {
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
}
}
})
}
}
+319 -131
View File
@@ -28,7 +28,7 @@ const (
)
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`
// ffmpeg, Yandex и хранилище подставляются в точке входа `cmd/transcriber`
// (docs/architecture.md, «Принципы»).
const core = "internal/service"
@@ -36,7 +36,6 @@ const core = "internal/service"
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{
"internal/controller/http": true,
"internal/controller/tg": true,
"internal/controller/worker": true,
}
@@ -79,7 +78,7 @@ func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) {
if strings.HasPrefix(imp, adapterPrefix) {
t.Errorf(
"%s импортирует адаптер %s: ядро зависит от интерфейсов "+
"internal/contract, а реализацию подставляет main.go",
"internal/contract, а реализацию подставляет cmd/transcriber",
core, imp,
)
}
@@ -114,6 +113,31 @@ func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
}
}
// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
// встроенного хранилища, а стала своей, и правило на это направление заводится
// впервые.
//
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
// `internal/contract`, а реализацию подставляет точка входа.
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !transports[pkg] {
continue
}
for _, imp := range imports {
if strings.HasPrefix(imp, adapterPrefix) {
t.Errorf(
"транспорт %s импортирует адаптер %s: реализацию подставляет "+
"cmd/transcriber, а транспорт знает только internal/contract",
pkg, imp,
)
}
}
}
}
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !strings.HasPrefix(pkg, adapterPrefix) {
@@ -174,186 +198,322 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
}
}
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения —
// поле теряется только у задачи, попавшей к воркеру.
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
// полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
// выполнялось бы само собой.
// Отображение работает **по имени колонки** — именованные параметры запроса и
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
// отображения нет вовсе.
const (
repoPkg = "internal/adapter/repo/pocketbase"
acquireFile = repoPkg + "/transcript_job_repo.go"
mappingFile = repoPkg + "/job_mapping.go"
repoPkg = "internal/adapter/repo/sqlite"
mappingFile = repoPkg + "/record_mapping.go"
migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go"
stateFile = "internal/entity/audio_record.go"
serviceFile = "internal/service/transcribe.go"
)
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
// касаются, а нашего кода — нет.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t)
read := readColumns(t)
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
query := acquireColumnNames(t)
row := rowColumnNames(t)
for _, col := range query {
if !row[col] {
for col := range written {
if !read[col] {
t.Errorf(
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
"она приедет нулевой, и первый Save затрёт сохранённое значение",
"колонку %q пишет отображение записи, но readRecordColumns её не "+
"читает: запись приедет из базы без этого поля",
col,
)
}
delete(row, col)
}
for col := range row {
for col := range read {
if !written[col] {
t.Errorf(
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
"читает, и поле остаётся нулевым",
col,
)
}
}
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for _, col := range acquireColumnNames(t) {
if col == "id" {
continue // ключ заводит само хранилище, шаг схемы его не объявляет
}
if !declared[col] {
t.Errorf(
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
"запрос отвалится на живой базе",
"колонку %q читает readRecordColumns, но её не пишет ни "+
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
col,
)
}
}
}
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
// выполнялось бы само собой — правило было бы зелёным всегда.
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(")
read := funcBody(t, mappingFile, "func recordToJob(")
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
// пишет этот ноль поверх сохранённого.
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
targets := readTargets(t)
used := rowFieldsTakenByEntity(t)
for _, col := range acquireColumnNames(t) {
if storageOwned[col] {
continue // эти колонки заводит и заполняет само хранилище
}
if !strings.Contains(write, `"`+col+`"`) {
for field, column := range targets {
if !used[field] {
t.Errorf(
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
"ни applyToRecord: путь через запись коллекции её потеряет",
col,
)
}
if !strings.Contains(read, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но recordToJob её не читает: задача, "+
"прочитанная не захватом, приедет без этого поля",
col,
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
"значение не доедет до сущности, а ближайшее сохранение запишет "+
"нулевое поверх сохранённого",
column, field,
)
}
}
}
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
// приезжает из захвата прочитанным и теряется на последнем шаге.
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
for _, field := range rowFieldNames(t) {
if !strings.Contains(body, "r."+field) {
for field := range used {
if _, ok := targets[field]; !ok {
t.Errorf(
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
"запроса, но в задачу не попадёт",
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
"колонки в него не кладёт: сущность получит нулевое значение всегда",
field,
)
}
}
}
// --- Чтение исходников ------------------------------------------------------
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
// значение константы известно на месте.
func acquireColumnNames(t *testing.T) []string {
t.Helper()
body := readFile(t, acquireFile)
const marker = "const acquireColumns = "
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
}
tail := body[start+len(marker):]
end := strings.Index(tail, "`\n")
if end < 0 {
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
}
var cols []string
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
if col := strings.TrimSpace(chunk); col != "" {
cols = append(cols, col)
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for col := range writtenColumns(t) {
if !declared[col] {
t.Errorf(
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
"заводит: сохранение отвалится на живой базе",
col,
)
}
}
if len(cols) == 0 {
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
}
return cols
}
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
func rowColumnNames(t *testing.T) map[string]bool {
// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
// инварианту проекта не пишется в журнал и не считается в метрику.
func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
for _, stage := range workingStageIdents(t) {
if !strings.Contains(body, "entity."+stage) {
t.Errorf(
"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
stage,
)
}
}
}
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
// Отбор списка — очередной потребитель словаря рубежей, и перечислять их у него
// строкой запроса нельзя: рубеж, добавленный конвейером, молча поменял бы состав
// всех трёх состояний отбора, а заметить это было бы нечем.
//
// Правило смотрит, что выборка списка берёт рубежи у дескриптора, а не пишет их
// литералом. Инвариант проекта «Рубеж объявляется одним дескриптором» компилятор
// не проверяет — проверяет оно.
func TestОтборСпискаБерётРубежиУДескриптора(t *testing.T) {
const listFile = repoPkg + "/record_list.go"
body := readFile(t, listFile)
for _, value := range declaredStateValues(t) {
if strings.Contains(body, `"`+value+`"`) {
t.Errorf(
"отбор списка называет рубеж %q строкой: рубеж, добавленный "+
"дескриптором, молча не попадёт ни в одно состояние отбора",
value,
)
}
}
for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} {
if !strings.Contains(body, fn) {
t.Errorf(
"отбор списка не зовёт %s: перечень рубежей обязан приходить из "+
"дескриптора, а не собираться по месту",
fn,
)
}
}
}
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
declared := map[string]bool{}
for _, stage := range stageIdents(t) {
declared[stage] = true
}
re := regexp.MustCompile(`case entity\.(\w+):`)
for _, m := range re.FindAllStringSubmatch(body, -1) {
if !declared[m[1]] {
t.Errorf(
"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
"дескрипторе: запись с этим рубежом захвату не выдаётся",
m[1],
)
}
}
}
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
// writtenColumns — колонки, которые пишет отображение записи в базу.
func writtenColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
funcBody(t, mappingFile, "func writeRecord(")
out := map[string]bool{}
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
}
return out
}
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
// обращается `toJob`.
func rowFieldNames(t *testing.T) []string {
// readColumns — колонки, которые читает обратное отображение. Перечень выборки
// собирается из той же карты, поэтому сверяется именно она.
func readColumns(t *testing.T) map[string]bool {
t.Helper()
var out []string
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
out = append(out, m[1])
body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]bool{}
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
}
return out
}
// rowStruct — текст объявления структуры `acquiredRow`.
func rowStruct(t *testing.T) string {
// readTarget — колонка чтения и поле сырой строки, куда она ложится.
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)
// rowFieldUse — обращение к полю сырой строки при сборке сущности.
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)
// readTargets — поле сырой строки в имя колонки, которая в него читается.
func readTargets(t *testing.T) map[string]string {
t.Helper()
body := readFile(t, mappingFile)
start := strings.Index(body, "type acquiredRow struct {")
body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]string{}
for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
out[m[2]] = m[1]
}
if len(out) == 0 {
t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
}
return out
}
// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func rowToAudioRecord(")
out := map[string]bool{}
for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
}
return out
}
// stageIdents — имена констант рубежей, перечисленных дескриптором.
func stageIdents(t *testing.T) []string {
t.Helper()
out, _ := stageDescriptor(t)
return out
}
// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
// не берут.
func workingStageIdents(t *testing.T) []string {
t.Helper()
_, working := stageDescriptor(t)
return working
}
func stageDescriptor(t *testing.T) (all []string, working []string) {
t.Helper()
body := readFile(t, stageFile)
const marker = "var stages = []Stage{"
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
}
end := strings.Index(body[start:], "\n}")
if end < 0 {
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
}
return body[start : start+end]
declared := declaredStates(t)
re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
if !declared[m[1]] {
t.Errorf(
"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
"в %s: перечень схемы разошёлся бы с ним молча",
m[1], stateFile,
)
continue
}
all = append(all, m[1])
if !strings.Contains(m[0], "Terminal: true") {
working = append(working, m[1])
}
}
if len(all) == 0 {
t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
}
if len(working) == 0 {
t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
}
return all, working
}
// declaredStates — константы рубежей, объявленные доменом.
// declaredStateValues — **значения** рубежей, а не имена их констант: правило
// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со
// значением.
//
// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие
// код, а не строки.
func declaredStateValues(t *testing.T) []string {
t.Helper()
out := []string{}
re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out = append(out, m[1])
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
func declaredStates(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
// --- Чтение исходников ------------------------------------------------------
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть.
@@ -371,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
return body[start : start+end]
}
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
// который её добавил, а переписывать применённый шаг нельзя.
// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
//
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
// поэтому объединённый — по всем шагам сразу.
func schemaFieldNames(t *testing.T) map[string]bool {
t.Helper()
dir := filepath.Join(repoRoot, migrationsPath)
@@ -381,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil {
t.Fatalf("читаю каталог шагов схемы: %v", err)
}
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
out := map[string]bool{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
@@ -391,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil {
t.Fatalf("читаю %s: %v", e.Name(), err)
}
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
for _, block := range tableBlocks(string(body), recordsTable) {
for _, m := range column.FindAllStringSubmatch(block, -1) {
out[m[1]] = true
}
}
}
if len(out) == 0 {
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
}
return out
}
// recordsTable — имя таблицы аудиозаписей в шагах схемы.
const recordsTable = "audio_records"
// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
// закрывающей скобки в начале строки.
func tableBlocks(body, table string) []string {
var out []string
marker := "CREATE TABLE " + table + " ("
for {
start := strings.Index(body, marker)
if start < 0 {
return out
}
body = body[start+len(marker):]
end := strings.Index(body, "\n\t\t)")
if end < 0 {
return out
}
out = append(out, body[:end])
body = body[end:]
}
}
func readFile(t *testing.T, rel string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
+167 -101
View File
@@ -3,33 +3,109 @@ package config
import (
"errors"
"fmt"
"net/url"
"net/netip"
"os"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type Config struct {
Server ServerConfig `toml:"server"`
Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
}
// PipelineConfig — настройки конвейера расшифровки.
type PipelineConfig struct {
// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
Workers int `toml:"workers"`
// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
// наблюдается по самому процессу, а остановка обратима — снятие признака
// возвращает запись на её рубеж.
OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
// идёт в сторону долгого: ложная остановка хуже поздней.
ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
}
// StuckLimits переводит настройки в пределы простоя.
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
return entity.StuckLimits{
Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
}
}
// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
// опечатки обязан старт.
func (c PipelineConfig) Validate() error {
if c.Workers < 0 {
return errors.New("pipeline: число воркеров не может быть отрицательным")
}
if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
return errors.New("pipeline: пределы простоя задаются положительным числом минут")
}
return nil
}
type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
// Debug — предохранитель отладочного запуска. Умолчание — «выключено»:
// отсутствие ключа читается как боевой прогон, а не как отладочный.
//
// Означает он одно: прогон идёт на машине разработчика, и сервису позволено
// подставить то, что в бою даёт окружение. Сегодня подставляется ровно одна
// вещь — заголовки входа, — и перечень следствий закрыт: уровня журнала,
// текстов внутренних отказов, ограничителя частоты, подмены распознавателя и
// проверок старта признак не касается. Новое следствие вешается на него
// только отдельной нормой спеки `access`.
Debug bool `toml:"debug"`
}
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
// записей. Двух путей, как было раньше, у хранилища не бывает.
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
//
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
// было раньше, у хранилища не бывает.
type StorageConfig struct {
DataDir string `toml:"data_dir"`
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
//
// Ключом, а не константой кода: крутят его при отказе «база занята» под
// несколькими воркерами, и подбор ответа на такой отказ не должен требовать
// пересборки образа.
BusyTimeoutMs int `toml:"busy_timeout_ms"`
// ReadConnections — сколько соединений держит читающий пул. Пишущее
// соединение при этом всегда одно, и настройкой оно не делается: драйвер
// пишет единственным соединением, и второе означало бы отказы по занятости.
ReadConnections int `toml:"read_connections"`
}
// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не
// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (c StorageConfig) Validate() error {
if strings.TrimSpace(c.DataDir) == "" {
return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей")
}
if c.BusyTimeoutMs <= 0 {
return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд")
}
if c.ReadConnections <= 0 {
return errors.New("storage: read_connections задаётся положительным числом соединений")
}
return nil
}
type YandexConfig struct {
@@ -42,91 +118,79 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
// AuthConfig — кому сервис верит на входе.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
// конфига вместе с протоколом.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
// той же строкой, которой он обходится, не барьер вовсе.
TrustedProxies []string `toml:"trusted_proxies"`
// TestHeaders — имитация заголовков, которые в бою ставит обратный прокси.
// Ключ — имя заголовка, значение — то, чем сервис назовёт пришедшего сам.
//
// Работает только при включённом предохранителе `[server] debug`, и
// заполненная секция без него роняет старт: состояние «имитация есть,
// предохранителя нет» не читается никак, а обе его прочтения — поломка.
//
// Умолчание — пустая секция. Непустой она считается по наличию ключа, каким
// бы ни было его значение: `Remote-User = ""` — заполненная имитация, а не
// отсутствие её.
TestHeaders map[string]string `toml:"test_headers"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
// TrustedNetworks разбирает перечень доверенных адресов.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
// человек.
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
value := strings.TrimSpace(raw)
if prefix, err := netip.ParsePrefix(value); err == nil {
networks = append(networks, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(value)
if err != nil {
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
}
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
}
return networks, nil
}
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
const trustedProxiesKey = "trusted_proxies"
// Validate проверяет, что сервису есть кому верить.
//
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
// отказом.
//
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
// проверяется только тем, что кто-то не смог войти.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
if len(c.TrustedProxies) == 0 {
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
if _, err := c.TrustedNetworks(); err != nil {
return err
}
return nil
@@ -142,6 +206,22 @@ func defaultConfig() *Config {
},
Storage: StorageConfig{
DataDir: "data",
// Пять секунд ожидания и четыре читающих соединения: числа выведены
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
// пережить чужую запись, а не чужую работу: пишет сервис короткими
// операциями, и очередь из трёх воркеров укладывается в него с
// запасом.
BusyTimeoutMs: 5000,
ReadConnections: 4,
},
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{
FolderID: "",
@@ -152,14 +232,9 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{
SecureCookie: true,
},
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
// значение соврало бы ровно там, где по нему решают, кого пускать.
Auth: AuthConfig{},
}
}
@@ -173,19 +248,10 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig()
// Load configuration from file
meta, err := toml.DecodeFile(path, &config)
if err != nil {
if _, err := toml.DecodeFile(path, &config); err != nil {
return nil, decodeError(path, err)
}
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil
}
+147 -166
View File
@@ -1,163 +1,69 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
// исполнялась ни разу.
// Проверка перечня доверенных адресов — единственная страховка от того, чтобы
// сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда:
// все адреса приложения просто отвечали бы отказом.
func validAuthConfig() AuthConfig {
return AuthConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "secret-value",
RedirectURL: "https://transcriber.example.com/auth/callback",
}
return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}}
}
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
if err := validAuthConfig().Validate(); err != nil {
t.Fatalf("заполненный конфиг отвергнут: %v", err)
t.Fatalf("заполненный перечень отвергнут: %v", err)
}
}
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
cases := map[string]func(*AuthConfig){
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
"client_id": func(c *AuthConfig) { c.ClientID = "" },
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
}
for key, clear := range cases {
t.Run(key, func(t *testing.T) {
cfg := validAuthConfig()
clear(&cfg)
err := cfg.Validate()
// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не
// узнавая, и молчать об этом старт не вправе.
func TestAuthConfigValidateRejectsEmptyList(t *testing.T) {
err := AuthConfig{}.Validate()
if err == nil {
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим")
}
if !strings.Contains(err.Error(), key) {
t.Fatalf("имя ключа %s не названо: %v", key, err)
}
})
if !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
// и значения секрета в нём быть не должно — только имя ключа.
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
cfg := validAuthConfig()
cfg.ClientSecret = "super-secret-value"
cfg.AuthURL = ""
// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем,
// что кто-то не смог войти.
func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) {
for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} {
t.Run(value, func(t *testing.T) {
cfg := AuthConfig{TrustedProxies: []string{value}}
err := cfg.Validate()
if err == nil {
t.Fatal("отказа нет")
t.Fatalf("строка %q принята как адрес", value)
}
if strings.Contains(err.Error(), "super-secret-value") {
t.Fatalf("значение секрета попало в текст отказа: %v", err)
}
}
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
cases := map[string]string{
"без схемы": "auth.example.com/api/oidc/authorization",
"пробел спереди": " https://auth.example.com/authorize",
"чужая схема": "ftp://auth.example.com/authorize",
"пустой хост": "https:///authorize",
"не адрес вовсе": "todo: заполнить",
}
for name, value := range cases {
t.Run(name, func(t *testing.T) {
cfg := validAuthConfig()
cfg.AuthURL = value
err := cfg.Validate()
if err == nil {
t.Fatalf("негодный адрес %q пропущен", value)
}
if !strings.Contains(err.Error(), "auth_url") {
if !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("имя ключа не названо: %v", err)
}
})
}
}
// Признак включения объявляет намерение, ключ доступа означает только доступ.
// Пока эти два значения жили в одном поле, пустой токен читался разом как
// «вход выключен» и как «ключ не доехал».
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
if err := cfg.Validate(); err != nil {
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
// Одиночный адрес принимается наравне с подсетью и становится подсетью на один
// адрес: писать разрядность руками значит её помнить, а перечень читает человек.
func TestTrustedNetworksAcceptsBareAddress(t *testing.T) {
networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks()
if err != nil {
t.Fatalf("одиночный адрес отвергнут: %v", err)
}
}
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: ""}
err := cfg.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
if len(networks) != 1 {
t.Fatalf("подсетей %d, ожидалась одна", len(networks))
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
// обычное состояние локального прогона, а не ошибка настройки.
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
cfg := TelegramConfig{Enabled: false, BotToken: ""}
if err := cfg.Validate(); err != nil {
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
}
}
// Половина требования «сообщение не несёт значения ключа» на этой проверке
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
//
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
// третий пойманный случай проверки, не способной упасть.
//
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
if err := filled.Validate(); err != nil {
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
}
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
if !networks[0].IsSingleIP() {
t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0])
}
}
@@ -172,49 +78,17 @@ func writeConfig(t *testing.T, body string) string {
}
const validConfigBody = `
[telegram]
enabled = false
bot_token = ""
[storage]
data_dir = "data"
`
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
// нулевое значение bool у обоих одинаковое.
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
_, err := LoadConfig(path)
if err == nil {
t.Fatal("файл без признака включения принят")
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("имя недостающего ключа не названо: %v", err)
}
}
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
for _, enabled := range []bool{true, false} {
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
cfg, err := LoadConfig(writeConfig(t, body))
if err != nil {
t.Fatalf("годный файл отвергнут: %v", err)
}
if cfg.Telegram.Enabled != enabled {
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
}
})
}
}
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
const secret = "123456:AAHfake-secret-token-value"
const secret = "AQVNfake-secret-api-key-value"
// Кавычка не закрыта: разбор оборвётся на значении.
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
path := writeConfig(t, "[yandex]\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\n")
_, err := LoadConfig(path)
if err == nil {
@@ -222,7 +96,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
}
message := err.Error()
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
for _, part := range []string{secret, "AQVNfake", "secret-api-key-value"} {
if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
}
@@ -231,7 +105,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
if !strings.Contains(message, "строке 3") {
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
}
if !strings.Contains(message, "bot_token") {
if !strings.Contains(message, "speech_kit_api_key") {
t.Fatalf("ключ не назван, чинить нечего: %v", err)
}
}
@@ -255,9 +129,9 @@ func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
// обратно.
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
const secret = "123456:AAHsecret-token-value"
const secret = "AQVNsecret-api-key-value"
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
path := writeConfig(t, "[yandex\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\"\n")
_, err := LoadConfig(path)
if err == nil {
@@ -265,7 +139,7 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
}
message := err.Error()
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
for _, part := range []string{secret, "AQVNsecret", "secret-api-key-value"} {
if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
}
@@ -274,3 +148,110 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
}
}
// Числа конвейера приезжают из файла, а не выдумываются кодом.
func TestLoadConfigReadsPipelineSettings(t *testing.T) {
path := writeConfig(t, `
[pipeline]
workers = 7
own_work_limit_minutes = 90
foreign_work_limit_minutes = 720
[storage]
data_dir = "data"
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers != 7 {
t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers)
}
limits := cfg.Pipeline.StuckLimits()
if limits.Own != 90*time.Minute {
t.Errorf("предел своей работы не прочитан: %v", limits.Own)
}
if limits.Foreign != 720*time.Minute {
t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign)
}
}
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
// поднимается с рабочими значениями.
func TestPipelineSettingsHaveDefaults(t *testing.T) {
path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers <= 0 {
t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers)
}
if err := cfg.Pipeline.Validate(); err != nil {
t.Errorf("умолчания не проходят собственную проверку: %v", err)
}
}
// Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел —
// опечатка: подниматься с ней значит остановить всякую запись первым же
// захватом.
func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}
if err := valid.Validate(); err != nil {
t.Errorf("ноль воркеров объявлен законным значением: %v", err)
}
for name, cfg := range map[string]PipelineConfig{
"отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440},
"нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440},
"нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0},
} {
if err := cfg.Validate(); err == nil {
t.Errorf("%s принято за режим", name)
}
}
}
// Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает
// свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание
// отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без
// предела, а пустой каталог данных оставляет сервис без места под базу и файлы.
// Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на
// подъёме.
func TestStorageConfigValidate(t *testing.T) {
valid := StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 4}
if err := valid.Validate(); err != nil {
t.Fatalf("заполненные настройки отвергнуты: %v", err)
}
cases := []struct {
name string
config StorageConfig
mention string
}{
{"каталог данных не заполнен", StorageConfig{DataDir: "", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"каталог данных из одних пробелов", StorageConfig{DataDir: " ", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"ожидание нулевое", StorageConfig{DataDir: "data", BusyTimeoutMs: 0, ReadConnections: 4}, "busy_timeout_ms"},
{"ожидание отрицательное", StorageConfig{DataDir: "data", BusyTimeoutMs: -1, ReadConnections: 4}, "busy_timeout_ms"},
{"пул чтения нулевой", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 0}, "read_connections"},
{"пул чтения отрицательный", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: -3}, "read_connections"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.Validate()
if err == nil {
t.Fatal("негодная настройка принята: поломка дошла бы до боя")
}
if !strings.Contains(err.Error(), tc.mention) {
t.Fatalf("имя ключа %q не названо: %v", tc.mention, err)
}
})
}
}
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"fmt"
"net/textproto"
"sort"
"strings"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Имена ключей в отказах старта. Литералы по одному на пакет: два разошлись бы
// молча, и владелец искал бы в конфиге ключ, которого там нет.
const (
debugKey = "[server] debug"
testHeadersKey = "[auth.test_headers]"
)
// HeaderSubstitution отдаёт **что** сервис подставит запросу вместо заголовков
// обратного прокси — и этим же перечнем отвечает на «подставляет ли»: пустой
// перечень значит «не подставляет».
//
// Признака вторым значением нет намеренно. Предикат, посчитанный дважды —
// булевым ответом здесь и непустотой карты у потребителя, — разошёлся бы молча,
// и сервис либо подставлял бы молча, либо молча не подставлял. Представление
// одно: непустота карты, и судят её все одинаково — проверка старта, строка
// журнала при старте и слой подстановки.
//
// Ключи возвращаемой карты приведены к каноническому виду имени заголовка: в
// HTTP имя нечувствительно к регистру, а ключ TOML чувствителен.
//
// Перечень пуст и тогда, когда предохранитель включён, а имитация пуста: сам по
// себе признак ничего не включает.
func (c *Config) HeaderSubstitution() map[string]string {
if !c.Server.Debug || len(c.Auth.TestHeaders) == 0 {
return nil
}
headers := make(map[string]string, len(c.Auth.TestHeaders))
for name, value := range c.Auth.TestHeaders {
headers[textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name))] = value
}
return headers
}
// ValidateTestHeaders судит настройки отладочного входа на старте, до приёма
// трафика: настройка, отданная на честность выкладки, проверяется только тем,
// что чужой архив уже уехал не тому.
//
// Имена заголовков приходят доводом, а не читаются отсюда: дом у них один —
// константы транспорта, — а пакет настроек транспорта не знает и знать не
// должен. Обратное ребро сделало бы `cmd/devtools`, которому нужен один разбор
// конфига, линкующим всю поверхность HTTP.
//
// Отказов четыре, и каждый закрывает своё «не читается никак»:
//
// - имитация заполнена, предохранитель выключен. Либо человек забыл включить
// предохранитель и будет искать поломку везде, кроме одного ключа, либо
// забыл убрать имитацию из боевого файла — и тогда до открытого архива
// остаётся одно слово;
// - два ключа секции дают одно каноническое имя заголовка. `Remote-User` и
// `remote-user` для TOML — два ключа, для HTTP — одно имя, и одно из двух
// значений потерялось бы молча;
// - имитация называет имя, которого сервис не читает. Опечатка `Remote-Usr`
// иначе кончается сервисом, который никого не узнаёт, без единого следа;
// - имитация непуста, а годного логина в ней нет. Секция с одним
// `Remote-Email` подняла бы сервис, который подставит почту, удалит логин и
// не узнает никого.
//
// Значений отказы не называют: логин — ключ к чужому архиву, и запрет печатать
// его действует на подставленное значение наравне с пришедшим.
func (c *Config) ValidateTestHeaders(accepted []string, loginHeader string) error {
headers := c.HeaderSubstitution()
if len(headers) == 0 {
if len(c.Auth.TestHeaders) > 0 {
return fmt.Errorf(
"auth: секция %s заполнена, а предохранитель %s выключен: "+
"либо включите предохранитель, либо уберите имитацию",
testHeadersKey, debugKey,
)
}
// Включённый предохранитель при пустой имитации законен: сам по себе он
// ничего не включает.
return nil
}
// Два ключа, различающиеся только регистром, дали бы одно имя заголовка и
// одно значение — второе потерялось бы молча.
if len(headers) != len(c.Auth.TestHeaders) {
return fmt.Errorf(
"auth: в секции %s два ключа называют один заголовок: "+
"имя заголовка нечувствительно к регистру, и одно из значений потерялось бы молча",
testHeadersKey,
)
}
known := make(map[string]bool, len(accepted))
for _, name := range accepted {
known[textproto.CanonicalMIMEHeaderKey(name)] = true
}
unknown := make([]string, 0, len(headers))
for name := range headers {
if !known[name] {
unknown = append(unknown, name)
}
}
if len(unknown) > 0 {
// Порядок перебора карты свой у каждого прогона, а отказ старта читает
// человек: без сортировки один и тот же конфиг давал бы разный текст.
sort.Strings(unknown)
return fmt.Errorf(
"auth: секция %s называет заголовок, которого сервис не читает: %s; принимаются %s",
testHeadersKey, strings.Join(unknown, ", "), strings.Join(accepted, ", "),
)
}
login, named := headers[textproto.CanonicalMIMEHeaderKey(loginHeader)]
if !named {
return fmt.Errorf(
"auth: секция %s не называет ключа %s: сервис подставил бы всё прочее и не узнал бы никого",
testHeadersKey, loginHeader,
)
}
if _, ok := entity.AcceptProviderLogin(login); !ok {
return fmt.Errorf(
"auth: значение ключа %s в секции %s не годится в логин: "+
"пустое, из одних пробельных знаков, длиннее %d знаков либо с управляющими знаками",
loginHeader, testHeadersKey, entity.MaxProviderLoginLength,
)
}
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package config
import (
"strings"
"testing"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
)
// Проверки этого файла судят требование «Настройка, открывающая вход всем,
// роняет старт». Предмет у них один: сервис, поднявшийся на настройках, при
// которых отладочный вход становится открытым входом либо не работает вовсе.
//
// Имена заголовков берутся у транспорта, а не выписываются здесь: дом у них
// один, и проверка со своим списком зеленела бы на разошедшемся коде.
func acceptedHeaderNames() []string { return httpcontroller.IdentityHeaderNames() }
// validateTestHeaders зовёт проверку так же, как её зовёт точка входа.
func validateTestHeaders(cfg *Config) error {
return cfg.ValidateTestHeaders(acceptedHeaderNames(), httpcontroller.LoginHeader)
}
// debugConfig собирает настройки отладочного запуска: предохранитель и имитация.
func debugConfig(debug bool, headers map[string]string) *Config {
cfg := defaultConfig()
cfg.Server.Debug = debug
cfg.Auth.TrustedProxies = []string{"127.0.0.1"}
cfg.Auth.TestHeaders = headers
return cfg
}
// Первый отказ: имитация заполнена, предохранителя нет. Состояние не читается
// никак — либо человек забыл включить предохранитель, либо забыл убрать
// имитацию из боевого файла, и до открытого архива остаётся одно слово.
func TestTestHeadersWithoutDebugFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(false, map[string]string{
httpcontroller.LoginHeader: "local",
}))
if err == nil {
t.Fatal("имитация без предохранителя принята: сервис поднялся бы никого не узнающим")
}
if !strings.Contains(err.Error(), "[server] debug") {
t.Fatalf("имя ключа предохранителя не названо, чинить нечего: %v", err)
}
}
// Пустое значение — заполненная имитация, а не отсутствие её: человек, написавший
// ключ, имитацию завёл, и пустое значение у него вторая поломка, а не первая.
func TestEmptyLoginValueCountsAsFilledSection(t *testing.T) {
err := validateTestHeaders(debugConfig(false, map[string]string{
httpcontroller.LoginHeader: "",
}))
if err == nil {
t.Fatal("секция с пустым значением сочтена пустой")
}
if !strings.Contains(err.Error(), "[server] debug") {
t.Fatalf("имя ключа предохранителя не названо: %v", err)
}
}
// Второй отказ: имя, которого сервис не читает. Опечатка иначе кончается
// сервисом, который никого не узнаёт, без единого следа.
func TestUnknownHeaderNameFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.LoginHeader: "local",
"Remote-Usr": "local",
}))
if err == nil {
t.Fatal("неизвестное имя заголовка принято")
}
message := err.Error()
if !strings.Contains(message, "Remote-Usr") {
t.Fatalf("неизвестное имя не названо: %v", err)
}
for _, name := range acceptedHeaderNames() {
if !strings.Contains(message, name) {
t.Fatalf("принимаемое имя %s не названо, чинить нечего: %v", name, err)
}
}
}
// Третий отказ, первая его половина: ключа логина нет вовсе. Такая секция
// подняла бы сервис, который подставит почту, удалит логин и не узнает никого.
func TestSectionWithoutLoginKeyFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.EmailHeader: "local@example.com",
}))
if err == nil {
t.Fatal("имитация без ключа логина принята")
}
if !strings.Contains(err.Error(), httpcontroller.LoginHeader) {
t.Fatalf("имя недостающего ключа не названо: %v", err)
}
}
// Третий отказ, вторая половина: логин судится **тем же** приёмом, каким
// узнавание судит пришедшее значение. Иначе сервис поднимается, ставит
// заголовок, получает отказ приёма и отвечает неузнанным на всё.
func TestUnacceptableLoginValueFailsStartup(t *testing.T) {
cases := map[string]string{
"пустое": "",
"пробельное": " ",
"сверх предела": strings.Repeat("x", 300),
"с управляющим знаком": "loc\x00al",
}
for name, login := range cases {
t.Run(name, func(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.LoginHeader: login,
}))
if err == nil {
t.Fatal("негодное значение логина принято")
}
message := err.Error()
if !strings.Contains(message, httpcontroller.LoginHeader) {
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
}
// Значение в отказ не идёт: логин — ключ к чужому архиву.
if login != "" && strings.Contains(message, login) {
t.Fatalf("значение логина уехало в отказ старта: %v", err)
}
})
}
}
// Два ключа, различающиеся регистром, назвали бы один заголовок: имя заголовка
// нечувствительно к регистру, а ключ TOML чувствителен, и одно из значений
// потерялось бы молча.
func TestDuplicateHeaderKeyFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
"Remote-User": "one",
"remote-user": "two",
}))
if err == nil {
t.Fatal("два ключа на один заголовок приняты: одно значение потерялось бы молча")
}
}
// Законный случай: предохранитель включён, имитация пуста. Сам по себе признак
// ничего не включает, и перечень доверенных адресов ему не судья.
func TestDebugWithoutTestHeadersStarts(t *testing.T) {
cfg := debugConfig(true, nil)
cfg.Auth.TrustedProxies = []string{"172.20.0.0/24"}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("включённый предохранитель при пустой имитации уронил старт: %v", err)
}
if len(cfg.HeaderSubstitution()) > 0 {
t.Fatal("пустая имитация включила подстановку")
}
}
// Законный случай главный: конфиг сегодняшнего дня, не называющий ни одного
// нового ключа, ведёт себя ровно как вёл.
func TestConfigWithoutNewKeysStartsUnchanged(t *testing.T) {
path := writeConfig(t, "[auth]\ntrusted_proxies = [\"172.20.0.0/24\"]\n"+validConfigBody)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг без новых ключей не прочитан: %v", err)
}
if cfg.Server.Debug {
t.Fatal("отсутствие ключа предохранителя прочитано как «включено»")
}
if len(cfg.Auth.TestHeaders) != 0 {
t.Fatalf("отсутствие секции имитации прочитано как заполненная: %v", cfg.Auth.TestHeaders)
}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("конфиг без новых ключей уронил старт: %v", err)
}
if cfg.HeaderSubstitution() != nil {
t.Fatal("конфиг без новых ключей включил подстановку")
}
}
// Негодное значение предохранителя роняет старт разбором, а не читается как
// «включено»: ошибка разбора не вправе открывать вход.
func TestMalformedDebugValueFailsLoad(t *testing.T) {
path := writeConfig(t, "[server]\ndebug = \"yes\"\n"+validConfigBody)
if _, err := LoadConfig(path); err == nil {
t.Fatal("строка вместо булева значения принята")
}
}
// Ключи имитации приезжают из файла, а на выходе приведены к каноническому виду
// имени заголовка: в HTTP имя нечувствительно к регистру, а ключ TOML — нет.
func TestHeaderSubstitutionReadsSectionAndCanonicalizes(t *testing.T) {
path := writeConfig(t, `
[server]
debug = true
[auth]
trusted_proxies = ["127.0.0.1"]
[auth.test_headers]
remote-user = "local"
REMOTE-EMAIL = "local@example.com"
`+validConfigBody)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг с имитацией не прочитан: %v", err)
}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("годная имитация уронила старт: %v", err)
}
headers := cfg.HeaderSubstitution()
if len(headers) == 0 {
t.Fatal("заполненная имитация при включённом предохранителе не включила подстановку")
}
if headers[httpcontroller.LoginHeader] != "local" {
t.Fatalf("логин не приведён к каноническому имени заголовка: %v", headers)
}
if headers[httpcontroller.EmailHeader] != "local@example.com" {
t.Fatalf("адрес почты не приведён к каноническому имени заголовка: %v", headers)
}
if _, named := headers[httpcontroller.NameHeader]; named {
t.Fatalf("не названный секцией заголовок появился в перечне: %v", headers)
}
}
+32 -7
View File
@@ -24,12 +24,37 @@ type AudioFileConverter interface {
Convert(ctx context.Context, src, dest string) error
}
// AudioRecognizer — внешний распознаватель речи.
//
// Заливка и отправка операции разделены: это два обращения с разной ценой
// повтора. Повтор заливки бесплатен и кладёт объект под тем же ключом; повтор
// отправки оплачивается наружу, и шаг обязан проверить сделанное прежде, чем
// платить второй раз.
//
// Результат отдаётся **доменным** — реплики со временем, плоский текст и байты
// ответа на хранение, — а не сырым форматом провайдера: разбор потока это
// обязанность адаптера, и ни один шаг конвейера не знает, каким потоком и какими
// полями провайдер отвечает.
type AudioRecognizer interface {
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(ctx context.Context, operationID string) (string, error)
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
}
type TelegramMessageSender interface {
Send(text string, chatId int64, replyToMessageId *int) error
// Provider — имя провайдера, под которым сохраняется попытка.
Provider() string
// Model — имя модели распознавания.
Model() string
// Upload кладёт аудио туда, откуда провайдер его прочитает, и отдаёт адрес.
// Повтор кладёт объект под тем же ключом и оплаты не стоит.
Upload(ctx context.Context, file io.Reader, objectKey string) (sourceURI string, err error)
// ObjectExists отвечает, лежит ли объект нужного размера. По нему шаг решает,
// повторять ли заливку; сверка содержимого хешем ненадёжна — признак
// целостности у составного объекта не равен отпечатку содержимого.
ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error)
// Submit заводит операцию распознавания по адресу аудио. Оплачивается
// наружу.
Submit(ctx context.Context, sourceURI string) (operationID string, err error)
// CheckStatus опрашивает операцию.
CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
// Fetch забирает готовый результат и отдаёт его доменным.
Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error)
// Parse строит доменный результат из **сохранённого** ответа провайдера, не
// обращаясь к нему. По нему архив пересчитывается без единого рубля.
Parse(raw []byte) (*entity.RecognitionOutcome, error)
}

Some files were not shown because too many files have changed in this diff Show More