хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@ version = 4 # версия раскладки; обратной совмест
|
|||||||
|
|
||||||
[docs]
|
[docs]
|
||||||
# каталог миграций: по нему docs.py сверяет схему с database.md
|
# каталог миграций: по нему docs.py сверяет схему с database.md
|
||||||
migrations = "internal/adapter/repo/pocketbase/migrations"
|
migrations = "internal/adapter/repo/sqlite/migrations"
|
||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# каталог задач от корня репозитория; имена частей — умолчания скрипта
|
# каталог задач от корня репозитория; имена частей — умолчания скрипта
|
||||||
|
|||||||
+3
-8
@@ -20,14 +20,9 @@ transcriber
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
# Database files
|
# Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
|
||||||
data/transcriber.db
|
# подкаталоги с файлами записей. Раскладку задаёт сервис.
|
||||||
data/transcriber.db-shm
|
data/
|
||||||
data/transcriber.db-wal
|
|
||||||
|
|
||||||
# Uploaded files
|
|
||||||
data/files/*
|
|
||||||
!data/files/.gitkeep
|
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ linters:
|
|||||||
- (*os.File).Close
|
- (*os.File).Close
|
||||||
- (io.ReadCloser).Close
|
- (io.ReadCloser).Close
|
||||||
- os.Remove
|
- os.Remove
|
||||||
|
# Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
|
||||||
|
# а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
|
||||||
|
# закрытия нет.
|
||||||
|
- (*database/sql.Rows).Close
|
||||||
|
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
|
||||||
|
# отвечает на него «уже закончена», и проверка этого отказа означала бы
|
||||||
|
# разбор штатного исхода.
|
||||||
|
- (*database/sql.Tx).Rollback
|
||||||
|
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
|
||||||
|
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
|
||||||
|
# браузере наполняла бы журнал ничем.
|
||||||
|
- (*encoding/json.Encoder).Encode
|
||||||
|
- (net/http.ResponseWriter).Write
|
||||||
|
|
||||||
exclusions:
|
exclusions:
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -11,10 +11,11 @@
|
|||||||
|
|
||||||
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
|
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
|
||||||
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
|
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
|
||||||
SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности.
|
SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
|
||||||
Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
|
Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
|
||||||
же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
|
рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
|
||||||
временно, до задачи, которая свяжет чат с учётной записью.
|
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
|
||||||
|
задачи, которая свяжет чат с учётной записью.
|
||||||
|
|
||||||
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
||||||
руками не правит и в форматы документов не экспортирует, учётных записей не
|
руками не правит и в форматы документов не экспортирует, учётных записей не
|
||||||
@@ -25,8 +26,10 @@ SpeechKit и отдаёт текст тому, кто запись загруз
|
|||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
|
||||||
панель администратора, — `aws-sdk-go-v2` для Object
|
`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
|
||||||
|
маршруты и слои на `net/http`, файлы записей своим каталогом,
|
||||||
|
`aws-sdk-go-v2` для Object
|
||||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
|
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
|
||||||
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
|
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
|
||||||
его Biome и юнит-тесты Vue. Сборка —
|
его Biome и юнит-тесты Vue. Сборка —
|
||||||
@@ -60,7 +63,8 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
|
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
|
||||||
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
|
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
|
||||||
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
|
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
|
||||||
на что. Чтение файла базы больше не равносильно чтению секрета.
|
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
|
||||||
|
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
|
||||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||||
пользователя и его сообщение в лог не пишутся — только длина и
|
пользователя и его сообщение в лог не пишутся — только длина и
|
||||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||||
@@ -85,20 +89,31 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
||||||
запись раз в секунду на каждый воркер. **major**
|
запись раз в секунду на каждый воркер. **major**
|
||||||
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
|
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
|
||||||
файлом шага. Необратимо: хранилище считает применённое по имени файла.
|
файлом шага. Необратимо: учёт применённого ведёт сама база.
|
||||||
**critical**
|
**critical**
|
||||||
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
|
*Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
|
||||||
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
|
встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
|
||||||
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
|
||||||
|
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
|
||||||
|
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
|
||||||
|
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
|
||||||
|
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
|
||||||
|
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
|
||||||
|
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
|
||||||
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
||||||
записи. **critical**
|
записи. **critical**
|
||||||
- **Колонки записи правятся в двух местах** пакета хранилища —
|
- **Колонки записи правятся в трёх местах** пакета хранилища —
|
||||||
`applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, —
|
`writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
|
||||||
плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из
|
`rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
|
||||||
них, теряется молча — запись сохранится без поля либо приедет с нулевым.
|
забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
|
||||||
|
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
|
||||||
|
ноль поверх сохранённого.
|
||||||
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
|
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
|
||||||
возвращает идентификатор и признак своего захвата, и перечень перестал расти
|
возвращает идентификатор и признак своего захвата, и перечень перестал расти
|
||||||
с моделью. Сверку держат правила `internal/archrules`. **major**
|
с моделью. Отображение при этом идёт **по имени колонки**: именованные
|
||||||
|
параметры запроса и место назначения, найденное по имени, — позиционный список
|
||||||
|
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
|
||||||
|
`internal/archrules`. **major**
|
||||||
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
|
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
|
||||||
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
|
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
|
||||||
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
|
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
|
||||||
@@ -108,15 +123,16 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
|
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
|
||||||
Признак захвата уникален для каждого захвата, и запись результата условна по
|
Признак захвата уникален для каждого захвата, и запись результата условна по
|
||||||
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
||||||
другому — по протуханию срока или после того, как человек снял признак
|
другому — по протуханию срока или после того, как человек вернул запись в
|
||||||
остановки в панели, — завершается без записи результата. Условие
|
работу подкомандой оснастки, — завершается без записи результата. Условие
|
||||||
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
||||||
по очереди, портя её результат. **major**
|
по очереди, портя её результат. **major**
|
||||||
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
|
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
|
||||||
запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и
|
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
|
||||||
держит это схема хранилища, а не договорённость. Пока обязательность жила в
|
держит это схема, а не договорённость: колонка объявлена внешним ключом на
|
||||||
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила
|
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
|
||||||
денег на распознавание и не доставалась потом никому. Правило со стороны
|
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
|
||||||
|
распознавание и не доставалась потом никому. Правило со стороны
|
||||||
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
|
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
|
||||||
записью, потому что схема запрещает **заводить** ничью, а это правило —
|
записью, потому что схема запрещает **заводить** ничью, а это правило —
|
||||||
**спрашивать** ничьим именем. **major**
|
**спрашивать** ничьим именем. **major**
|
||||||
@@ -137,6 +153,7 @@ gofmt -l .
|
|||||||
golangci-lint run
|
golangci-lint run
|
||||||
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||||
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
|
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
|
||||||
|
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
|
||||||
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||||
task gate # весь набор проверок разом
|
task gate # весь набор проверок разом
|
||||||
@@ -247,8 +264,8 @@ Node на машину **не ставится**: шаг сборки прило
|
|||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
||||||
база (`data/data.db`), и записи живых людей
|
база (`data/transcriber.db`), и записи живых людей
|
||||||
(`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис
|
(`data/records/<запись>/`). На стройке под ним пусто и сервис
|
||||||
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
|
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
|
||||||
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
||||||
его ронять и пересоздавать можно свободно.
|
его ронять и пересоздавать можно свободно.
|
||||||
|
|||||||
@@ -8,16 +8,17 @@
|
|||||||
- Конвертация в ogg через ffmpeg
|
- Конвертация в ogg через ffmpeg
|
||||||
- Распознавание речи через Yandex SpeechKit
|
- Распознавание речи через Yandex SpeechKit
|
||||||
- Отслеживание статуса задач расшифровки
|
- Отслеживание статуса задач расшифровки
|
||||||
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
|
- Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- **Язык**: Go 1.26, CGO не нужен
|
- **Язык**: Go 1.26, CGO не нужен
|
||||||
- **Веб-фреймворк**: gin-gonic/gin
|
- **HTTP**: стандартная библиотека, `net/http`
|
||||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||||
- **Конвертация**: ffmpeg
|
- **Конвертация**: ffmpeg
|
||||||
- **Хранилище, файлы и панель**: встроенная PocketBase
|
- **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
|
||||||
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
|
- **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
|
||||||
|
- **Файлы записей**: свой каталог, подкаталог на запись
|
||||||
- **Метрики**: prometheus/client_golang
|
- **Метрики**: prometheus/client_golang
|
||||||
|
|
||||||
## Установка и запуск
|
## Установка и запуск
|
||||||
@@ -75,13 +76,13 @@ inv pl -- transcriber
|
|||||||
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
|
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
|
||||||
записи, `GET /app/audiorecords` — страница своих записей,
|
записи, `GET /app/audiorecords` — страница своих записей,
|
||||||
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
|
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
|
||||||
текст названного вида, `GET /app/me` — кто пришёл, `GET /app/config` — пределы,
|
текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
|
||||||
которые сервис объявляет приложению. Отдельными адресами стоят `GET /metrics` —
|
копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
|
||||||
метрики Prometheus с префиксом `transcriber_` — и `GET /health` — проверка
|
объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
|
||||||
живости. Своего входа у сервиса нет: кто пришёл, называет заголовок обратного
|
Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
|
||||||
прокси ([access](openspec/specs/access/spec.md)). Сверх этого тем же портом
|
Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
|
||||||
отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
|
([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
|
||||||
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
|
ничего: всякий прочий путь получает разметку приложения.
|
||||||
|
|
||||||
Контракт приёма и опроса нормативен и живёт в
|
Контракт приёма и опроса нормативен и живёт в
|
||||||
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
|
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
|
||||||
@@ -91,7 +92,7 @@ inv pl -- transcriber
|
|||||||
## Состояния задач
|
## Состояния задач
|
||||||
|
|
||||||
Перечень состояний, переходы между ними и число воркеров —
|
Перечень состояний, переходы между ними и число воркеров —
|
||||||
[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление
|
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
|
||||||
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
|
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
@@ -100,9 +101,10 @@ inv pl -- transcriber
|
|||||||
transcriber/
|
transcriber/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
||||||
│ └── devtools/ # Оснастка разработчика: подставной прокси для локального входа
|
│ └── devtools/ # Оснастка разработчика: подставной прокси и возврат записи в работу
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── entity/ # Модели: задача, файл, результат распознавания
|
│ ├── entity/ # Модели: запись, файл, результат распознавания
|
||||||
|
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
|
||||||
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
||||||
│ ├── config/ # Разбор config.toml
|
│ ├── config/ # Разбор config.toml
|
||||||
│ ├── metrics/ # Метрики Prometheus
|
│ ├── metrics/ # Метрики Prometheus
|
||||||
@@ -114,26 +116,32 @@ transcriber/
|
|||||||
│ ├── converter/ffmpeg/ # Конвертация аудио
|
│ ├── converter/ffmpeg/ # Конвертация аудио
|
||||||
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
||||||
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
||||||
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
|
│ └── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
|
||||||
└── data/ # Каталог данных: база и файлы записей вместе
|
└── data/ # Каталог данных: база и файлы записей вместе
|
||||||
├── data.db # База хранилища (создаётся автоматически)
|
├── transcriber.db # База (создаётся автоматически)
|
||||||
└── storage/ # Файлы записей в раскладке хранилища
|
├── migrate.lock # Замок наката схемы
|
||||||
|
└── records/ # Файлы записей: подкаталог на запись
|
||||||
```
|
```
|
||||||
|
|
||||||
## Хранилище
|
## Хранилище
|
||||||
|
|
||||||
Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и
|
Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и
|
||||||
идентификаторов, а также механика захвата задачи воркером —
|
идентификаторов, раскладка файлов записи и механика захвата задачи воркером —
|
||||||
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
|
[docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное
|
||||||
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
|
его действие вне экранов — возврат остановленной записи в работу подкомандой
|
||||||
в журнал при первом запуске.
|
оснастки.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/devtools resume -c config.toml <идентификатор записи>
|
||||||
|
```
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Схему двигают шаги миграций PocketBase на Go —
|
Схему двигают шаги `pressly/goose/v3` —
|
||||||
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
|
`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
|
||||||
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
|
начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
|
||||||
Применённый шаг не переписывается: изменение — только новым файлом шага.
|
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
|
||||||
|
переписывается: изменение — только новым файлом шага.
|
||||||
|
|
||||||
Проверки перед коммитом — одной командой:
|
Проверки перед коммитом — одной командой:
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||||
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||||
//
|
//
|
||||||
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт
|
// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
|
||||||
// подкомандой `admin` вместе с задачей о запуске одной командой.
|
// остановленной записи в работу. Вторая встала на место панели владельца:
|
||||||
|
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
|
||||||
//
|
//
|
||||||
// Имена заголовков берутся **константами транспорта**, а не литералами: они
|
// Имена заголовков берутся **константами транспорта**, а не литералами: они
|
||||||
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
|
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
|
||||||
@@ -43,6 +44,8 @@ func main() {
|
|||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "proxy":
|
case "proxy":
|
||||||
runProxy(os.Args[2:])
|
runProxy(os.Args[2:])
|
||||||
|
case "resume":
|
||||||
|
runResume(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
||||||
usage()
|
usage()
|
||||||
@@ -54,7 +57,8 @@ func usage() {
|
|||||||
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||||
|
|
||||||
Подкоманды:
|
Подкоманды:
|
||||||
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
||||||
|
resume вернуть остановленную запись в работу
|
||||||
|
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Перечень адресного пространства для проверок журнала. Обработчиков он здесь
|
|
||||||
// не вешает: журналу нужны только границы, а не то, что стоит за ними.
|
|
||||||
func journalMounts() []httpcontroller.Mount {
|
|
||||||
return []httpcontroller.Mount{
|
|
||||||
{Path: httpcontroller.StorageRoot},
|
|
||||||
{Path: httpcontroller.PanelRoot},
|
|
||||||
{Path: httpcontroller.AppRoot},
|
|
||||||
{Path: httpcontroller.HealthPath, Exact: true},
|
|
||||||
{Path: httpcontroller.MetricsPath, Exact: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
|
|
||||||
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
|
|
||||||
// ссылку целиком. Инвариант проекта, critical.
|
|
||||||
func TestJournalRouteHidesStoredFileName(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ссылка на файл теряет имя",
|
|
||||||
path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3",
|
|
||||||
want: "/api/files/files/abc123def456ghi/<имя>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "маршрут остаётся различимым",
|
|
||||||
path: "/api/files/files/abc123def456ghi/запись.ogg",
|
|
||||||
want: "/api/files/files/abc123def456ghi/<имя>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "прочие пути не трогаются",
|
|
||||||
path: "/app/audiorecords/abc123def456ghi",
|
|
||||||
want: "/app/audiorecords/abc123def456ghi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "приём не трогается",
|
|
||||||
path: "/app/audiorecords",
|
|
||||||
want: "/app/audiorecords",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "сам префикс без имени не портится",
|
|
||||||
path: "/api/files/",
|
|
||||||
want: "/api/files/",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, c.want, journalRoute(c.path, journalMounts()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не
|
|
||||||
// устройство — переписанная реализация обязана остаться зелёной.
|
|
||||||
func TestJournalRouteDropsNameEntirely(t *testing.T) {
|
|
||||||
const stored = "0f7b8dd3-d1cc-424c.mp3"
|
|
||||||
|
|
||||||
route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts())
|
|
||||||
|
|
||||||
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
|
|
||||||
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не
|
|
||||||
// идёт: множеством его значений распоряжается спрашивающий.
|
|
||||||
func TestJournalRouteHidesWebappPath(t *testing.T) {
|
|
||||||
cases := []string{
|
|
||||||
"/",
|
|
||||||
"/records/abc123def456ghi",
|
|
||||||
"/" + strings.Repeat("a", 1024),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range cases {
|
|
||||||
route := journalRoute(path, journalMounts())
|
|
||||||
|
|
||||||
assert.Equal(t, webappRoute, route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+85
-188
@@ -9,29 +9,31 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
||||||
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
"git.vakhrushev.me/av/transcriber/web"
|
"git.vakhrushev.me/av/transcriber/web"
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
|
||||||
|
// кончается ненулевым кодом выхода.
|
||||||
|
//
|
||||||
|
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
|
||||||
|
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
|
||||||
|
// распознавания.
|
||||||
func main() {
|
func main() {
|
||||||
// Создаем структурированный логгер
|
// Создаем структурированный логгер
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
@@ -39,34 +41,36 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
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
|
// Parse command line flags
|
||||||
configPath := flag.String("c", "config.toml", "Path to config file")
|
configPath := flag.String("c", "config.toml", "Path to config file")
|
||||||
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
|
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
cfg, err := config.LoadConfig(*configPath)
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Unable to load configuration", "config_path", *configPath, "error", err)
|
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
|
||||||
os.Exit(1)
|
|
||||||
} else {
|
|
||||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
|
||||||
}
|
}
|
||||||
|
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||||
|
|
||||||
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
|
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
|
||||||
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
|
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
|
||||||
// этом было бы неоткуда.
|
// этом было бы неоткуда.
|
||||||
if err := cfg.Auth.Validate(); err != nil {
|
if err := cfg.Auth.Validate(); err != nil {
|
||||||
logger.Error("Unable to start without trusted proxies", "error", err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
||||||
// значило бы платить за настройку, которая не меняется.
|
// значило бы платить за настройку, которая не меняется.
|
||||||
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Unable to read trusted proxies", "error", err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
||||||
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||||
@@ -77,8 +81,10 @@ func main() {
|
|||||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||||
// ней значит остановить всякую запись первым же захватом.
|
// ней значит остановить всякую запись первым же захватом.
|
||||||
if err := cfg.Pipeline.Validate(); err != nil {
|
if err := cfg.Pipeline.Validate(); err != nil {
|
||||||
logger.Error("Unable to start with incorrect pipeline settings", "error", err)
|
return err
|
||||||
os.Exit(1)
|
}
|
||||||
|
if err := cfg.Storage.Validate(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем переменные окружения из .env файла
|
// Загружаем переменные окружения из .env файла
|
||||||
@@ -86,32 +92,43 @@ func main() {
|
|||||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
|
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||||
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
|
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||||
// непринятые шаги прежде, чем поднять сервер.
|
ReadConnections: cfg.Storage.ReadConnections,
|
||||||
storage, err := pbrepo.New(cfg.Storage.DataDir)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to open storage", "error", err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := storage.ResetBootstrapState(); err != nil {
|
if err := db.Close(); err != nil {
|
||||||
logger.Error("Failed to close storage", "error", err)
|
logger.Error("Failed to close the database", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
pbrepo.BindPanelRules(storage)
|
// Создаем контекст для graceful shutdown
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Создаем репозитории
|
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
|
||||||
recordRepo := pbrepo.NewAudioRecordRepository(storage)
|
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||||
|
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
|
||||||
|
// причине их становятся сотни.
|
||||||
|
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{
|
repos := service.Repositories{
|
||||||
Records: recordRepo,
|
Records: recordRepo,
|
||||||
Files: pbrepo.NewFileRepository(storage),
|
Files: fileRepo,
|
||||||
Texts: pbrepo.NewTextRepository(storage),
|
Texts: sqliterepo.NewTextRepository(db),
|
||||||
Structures: pbrepo.NewStructureRepository(storage),
|
Structures: sqliterepo.NewStructureRepository(db),
|
||||||
Recognitions: pbrepo.NewRecognitionRepository(storage),
|
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||||
Events: pbrepo.NewRecordEventRepository(storage),
|
Events: sqliterepo.NewRecordEventRepository(db),
|
||||||
}
|
}
|
||||||
|
users := sqliterepo.NewUserRepository(db)
|
||||||
|
|
||||||
// Создаем адаптеры
|
// Создаем адаптеры
|
||||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||||
@@ -127,8 +144,7 @@ func main() {
|
|||||||
FolderID: cfg.Yandex.FolderID,
|
FolderID: cfg.Yandex.FolderID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create audio recognizer", "error", err)
|
return fmt.Errorf("failed to create audio recognizer: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
|
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
|
||||||
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
|
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
|
||||||
@@ -140,7 +156,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Создаем сервисы
|
|
||||||
transcribeService := service.NewTranscribeService(
|
transcribeService := service.NewTranscribeService(
|
||||||
repos,
|
repos,
|
||||||
metaviewer,
|
metaviewer,
|
||||||
@@ -150,10 +165,6 @@ func main() {
|
|||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Создаем контекст для graceful shutdown
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Создаем WaitGroup для ожидания завершения всех воркеров
|
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -166,113 +177,41 @@ func main() {
|
|||||||
pool.Start(ctx)
|
pool.Start(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
|
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
|
||||||
// убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
|
|
||||||
// а признак существует ради того дня, когда входов снова станет больше.
|
|
||||||
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||||
|
|
||||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
appHandler := httpcontroller.NewAppHandler(
|
||||||
// и второму серверу на нём взяться неоткуда.
|
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
|
||||||
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger)
|
)
|
||||||
|
|
||||||
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||||
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||||
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||||
// наблюдения и правило неизвестного пути у раздачи приложения.
|
// наблюдения, правило неизвестного пути у раздачи приложения и область
|
||||||
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler())
|
// действия узнавания.
|
||||||
|
mounts := httpcontroller.ServiceMounts(
|
||||||
|
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger),
|
||||||
|
promhttp.Handler(),
|
||||||
|
)
|
||||||
|
|
||||||
dist, appBuilt := web.Dist()
|
dist, appBuilt := web.Dist()
|
||||||
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
|
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
|
||||||
|
|
||||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
srv := &http.Server{
|
||||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
// раньше» между ними иначе нет.
|
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
|
||||||
srvCh := make(chan *http.Server, 1)
|
|
||||||
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
|
||||||
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
||||||
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
|
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
|
||||||
// целенаправленной нагрузке объявлена вне модели угроз проекта.
|
// модели угроз проекта.
|
||||||
se.Server.ReadTimeout = 0
|
ReadTimeout: 0,
|
||||||
srvCh <- se.Server
|
}
|
||||||
|
|
||||||
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
|
|
||||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
|
||||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
|
||||||
start := clock.Start()
|
|
||||||
err := e.Next()
|
|
||||||
|
|
||||||
level := slog.LevelInfo
|
|
||||||
if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) {
|
|
||||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
|
||||||
level = slog.LevelDebug
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := []any{
|
|
||||||
"http.method", e.Request.Method,
|
|
||||||
"http.route", journalRoute(e.Request.URL.Path, mounts),
|
|
||||||
"http.status_code", e.Status(),
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"transport", "http",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Путь, отданный приложению, в журнал не идёт — вместо него исход
|
|
||||||
// и длина: по ним видно, что происходит, а множеством значений
|
|
||||||
// самого пути распоряжается спрашивающий.
|
|
||||||
if outcome := httpcontroller.WebappOutcome(e); outcome != "" {
|
|
||||||
attrs = append(attrs,
|
|
||||||
"webapp.outcome", outcome,
|
|
||||||
"http.path_length", len(e.Request.URL.Path))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Log(e.Request.Context(), level, "Incoming request", attrs...)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
// Слой узнавания вешается на **корневой** роутер, а не под корнем
|
|
||||||
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
|
|
||||||
// принадлежит роутеру хранилища и группой не накрывается. Область его
|
|
||||||
// действия при этом выводится из перечня адресного пространства — см.
|
|
||||||
// `underIdentifiedArea`, — а не из места привязки.
|
|
||||||
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
|
|
||||||
|
|
||||||
// Своё правило ограничителя частоты под корень приложения. Правило
|
|
||||||
// хранилища настроено на его собственный корень и наших адресов больше не
|
|
||||||
// покрывает: вместе с переездом приложения ограничитель перестал бы
|
|
||||||
// существовать для него вовсе, и заметить это было бы нечем.
|
|
||||||
if err := httpcontroller.ApplyAppRateLimit(storage); err != nil {
|
|
||||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Хранилищу называется заголовок, из которого брать адрес
|
|
||||||
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
|
|
||||||
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
|
|
||||||
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
|
|
||||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
|
|
||||||
|
|
||||||
// Раздача приложения вешается последней: она занимает корень, и всё,
|
|
||||||
// что не совпало ни с одним адресом сервиса, доходит до неё.
|
|
||||||
webappHandler.Register(se.Router)
|
|
||||||
|
|
||||||
return se.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Запускаем HTTP сервер в отдельной горутине
|
|
||||||
serveErr := make(chan error, 1)
|
serveErr := make(chan error, 1)
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
||||||
err := apis.Serve(storage, apis.ServeConfig{
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
|
|
||||||
ShowStartBanner: false,
|
|
||||||
})
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
logger.Error("HTTP server error", "error", err)
|
|
||||||
serveErr <- err
|
serveErr <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -285,41 +224,36 @@ func main() {
|
|||||||
logger.Info("Press Ctrl+C to stop...")
|
logger.Info("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
// Ждем сигнал завершения либо отказ сервера
|
// Ждем сигнал завершения либо отказ сервера
|
||||||
|
var startupErr error
|
||||||
select {
|
select {
|
||||||
case <-sigChan:
|
case <-sigChan:
|
||||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||||
case <-serveErr:
|
case err := <-serveErr:
|
||||||
logger.Error("HTTP server stopped unexpectedly, shutting down")
|
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
|
||||||
|
startupErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера
|
// Останавливаем HTTP сервер
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||||
|
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
// Останавливаем HTTP сервер
|
logger.Info("Shutting down HTTP server...")
|
||||||
select {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
case srv := <-srvCh:
|
logger.Error("HTTP server forced to shutdown", "error", err)
|
||||||
logger.Info("Shutting down HTTP server...")
|
} else {
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
logger.Info("HTTP server stopped gracefully")
|
||||||
logger.Error("HTTP server forced to shutdown", "error", err)
|
|
||||||
} else {
|
|
||||||
logger.Info("HTTP server stopped gracefully")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
logger.Info("HTTP server was not started, nothing to shut down")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отменяем контекст для остановки воркеров
|
// Отменяем контекст для остановки воркеров
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
// Создаем канал для уведомления о завершении всех воркеров
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ждем завершения всех воркеров или таймаута
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
logger.Info("All workers stopped gracefully")
|
logger.Info("All workers stopped gracefully")
|
||||||
@@ -328,43 +262,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Transcriber service stopped")
|
logger.Info("Transcriber service stopped")
|
||||||
}
|
|
||||||
|
|
||||||
// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний
|
return startupErr
|
||||||
// сегмент такого пути и есть имя файла в хранилище.
|
|
||||||
const filesPathPrefix = "/api/files/"
|
|
||||||
|
|
||||||
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
|
||||||
const webappRoute = "<приложение>"
|
|
||||||
|
|
||||||
// journalRoute готовит путь запроса к записи в журнал.
|
|
||||||
//
|
|
||||||
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
|
|
||||||
// последняя часть ссылки `/api/files/...`, и строка журнала вместе с
|
|
||||||
// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь
|
|
||||||
// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в
|
|
||||||
// собранные логи при каждом скачивании записи.
|
|
||||||
//
|
|
||||||
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
|
|
||||||
// теряется.
|
|
||||||
//
|
|
||||||
// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи
|
|
||||||
// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку
|
|
||||||
// с кодом `200`: множеством его значений распоряжается спрашивающий, и
|
|
||||||
// дословная строка сделала бы журнал местом, куда аноним пишет свой текст.
|
|
||||||
func journalRoute(path string, mounts []httpcontroller.Mount) string {
|
|
||||||
if !httpcontroller.IsServiceAddress(mounts, path) {
|
|
||||||
return webappRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(path, filesPathPrefix) {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
cut := strings.LastIndex(path, "/")
|
|
||||||
if cut < len(filesPathPrefix) {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
return path[:cut+1] + "<имя>"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -4,11 +4,33 @@ port = 8080
|
|||||||
shutdown_timeout = 5
|
shutdown_timeout = 5
|
||||||
force_shutdown_timeout = 20
|
force_shutdown_timeout = 20
|
||||||
|
|
||||||
# Storage configuration
|
# Хранилище: каталог данных и числа его базы.
|
||||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
#
|
||||||
|
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
|
||||||
|
# записей. Двух путей у хранилища не бывает.
|
||||||
[storage]
|
[storage]
|
||||||
data_dir = "data"
|
data_dir = "data"
|
||||||
|
|
||||||
|
# Сколько ждать занятую базу, миллисекунды. Положительное число.
|
||||||
|
#
|
||||||
|
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
|
||||||
|
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
|
||||||
|
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
|
||||||
|
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
|
||||||
|
# сразу» и потому не принимается.
|
||||||
|
busy_timeout_ms = 5000
|
||||||
|
|
||||||
|
# Сколько соединений держит читающий пул. Положительное число.
|
||||||
|
#
|
||||||
|
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
|
||||||
|
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||||
|
# Число выводят из числа воркеров плюс запас под запросы приложения.
|
||||||
|
#
|
||||||
|
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
|
||||||
|
# означало бы отказы по занятости на записи результата шага, то есть после
|
||||||
|
# оплаченной работы.
|
||||||
|
read_connections = 4
|
||||||
|
|
||||||
# Конвейер расшифровки.
|
# Конвейер расшифровки.
|
||||||
[pipeline]
|
[pipeline]
|
||||||
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
|
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ PocketBase уходит из проекта целиком: состояние
|
|||||||
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
|
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
|
||||||
базе.
|
базе.
|
||||||
|
|
||||||
|
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
|
||||||
|
названо здесь.
|
||||||
|
|
||||||
|
Шаги схемы двигает библиотека `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-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-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
|
||||||
|
|||||||
+71
-59
@@ -21,18 +21,20 @@
|
|||||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||||
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||||||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||||
2026-08-14;
|
2026-08-14, `storage-without-pocketbase` 2026-08-22;
|
||||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||||
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||||||
повтором и молчание конвейера наружу: задачи
|
повтором и молчание конвейера наружу: задачи
|
||||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
||||||
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
|
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||||
2026-08-14. Переходы состояний и отмена
|
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
|
||||||
контекста посреди шага остаются
|
контекста посреди шага остаются
|
||||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
|
||||||
2026-08-12;
|
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
|
||||||
|
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
|
||||||
|
панель владельца исчезла и не заменена ничем;
|
||||||
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
||||||
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||||||
целиком и вложением, как из сохранённого строится структура реплик без
|
целиком и вложением, как из сохранённого строится структура реплик без
|
||||||
@@ -66,13 +68,15 @@
|
|||||||
|
|
||||||
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
|
||||||
захвата и порядок выборки нормирует
|
захвата и порядок выборки нормирует
|
||||||
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||||||
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||||||
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
[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), «Брошенная задача возвращается
|
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||||
@@ -83,11 +87,10 @@
|
|||||||
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
||||||
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||||
не знает ни ядра, ни транспортов.
|
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
|
||||||
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
|
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
|
||||||
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
|
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
|
||||||
роутер этого хранилища, а не наш сервер поверх него. Правила на это
|
и правило на это направление заведено впервые.
|
||||||
направление нет намеренно.
|
|
||||||
|
|
||||||
## Слои и модель домена
|
## Слои и модель домена
|
||||||
|
|
||||||
@@ -133,10 +136,10 @@
|
|||||||
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
||||||
полями, принадлежит `internal/entity`.
|
полями, принадлежит `internal/entity`.
|
||||||
|
|
||||||
Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт
|
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
|
||||||
знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого
|
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
|
||||||
хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача
|
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
|
||||||
файла и свои маршруты возвращают транспорту независимость от инфраструктуры.
|
пришедшего приходит ему интерфейсом `contract.UserRepository`.
|
||||||
|
|
||||||
## Компоненты
|
## Компоненты
|
||||||
|
|
||||||
@@ -146,14 +149,15 @@
|
|||||||
|
|
||||||
| Компонент | Где | Что делает |
|
| Компонент | Где | Что делает |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
|
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи |
|
||||||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
|
||||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
|
||||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
|
||||||
|
| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
|
||||||
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
||||||
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||||||
|
|
||||||
@@ -174,6 +178,12 @@
|
|||||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||||
|
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
|
||||||
|
нужен. База и файлы записей лежат под одним каталогом данных.
|
||||||
|
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
|
||||||
|
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
|
||||||
|
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
|
||||||
|
берём сами.
|
||||||
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||||||
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||||||
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||||||
@@ -191,34 +201,23 @@
|
|||||||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||||
сервис поднимает молча.
|
сервис поднимает молча.
|
||||||
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
|
||||||
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
|
||||||
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
|
||||||
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
|
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
|
||||||
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
|
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
|
||||||
двух бинарей на одном каталоге данных.
|
схемы нет и не планируется.
|
||||||
|
|
||||||
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
|
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
|
||||||
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
выкладки `record-centric-model` откат работал, после перестал, а с уходом
|
||||||
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
|
||||||
выкладки `record-centric-model` откат образа работает, после — нет.
|
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
|
||||||
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
|
потому, что риск сегодня чем-то грозит.
|
||||||
правила коллекции пользователей наглухо, а прежний образ заводил учётную
|
|
||||||
запись внутренним запросом обмена кода — и этот запрос закрытое правило
|
|
||||||
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
|
|
||||||
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
|
|
||||||
Порог тот же по форме, что и у `202608140002`: до выкладки
|
|
||||||
`trusted-header-login` откат работает, после — нет, и лечится он повторной
|
|
||||||
выкладкой вперёд. Обратного шага схемы нет и не планируется.
|
|
||||||
|
|
||||||
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
|
|
||||||
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
|
|
||||||
прямо, а не потому, что риск сегодня чем-то грозит.
|
|
||||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/intake, pipeline -->
|
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
|
||||||
|
|
||||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
@@ -226,7 +225,7 @@
|
|||||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
|
||||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||||
|
|
||||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||||||
@@ -237,8 +236,8 @@
|
|||||||
«падает приведение» отличается от «падает распознавание». Плюс логи
|
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||||||
контейнера. Отдельного оповещения нет.
|
контейнера. Отдельного оповещения нет.
|
||||||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||||
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
|
||||||
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
|
||||||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||||
вхолостую с паузой из
|
вхолостую с паузой из
|
||||||
[database.md](database.md), «Настройки с числовым значением».
|
[database.md](database.md), «Настройки с числовым значением».
|
||||||
@@ -248,7 +247,11 @@
|
|||||||
| Что | Где |
|
| Что | Где |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||||||
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
|
||||||
|
| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
|
||||||
|
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
|
||||||
|
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
|
||||||
|
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
|
||||||
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||||||
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||||||
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
|
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
|
||||||
@@ -264,13 +267,17 @@
|
|||||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||||
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
|
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` |
|
||||||
|
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
|
||||||
|
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||||||
|
|
||||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
|
||||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение
|
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
|
||||||
доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик
|
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
|
||||||
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл.
|
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
|
||||||
|
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
|
||||||
|
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
@@ -320,8 +327,9 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
|||||||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||||
зависит возвращение убранного входа.
|
зависит возвращение убранного входа.
|
||||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
Второго периметра на порту сервиса при этом не осталось: панель администратора
|
||||||
суперпользователя.
|
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
|
||||||
|
больше нечего.
|
||||||
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||||||
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||||||
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||||||
@@ -343,7 +351,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
|||||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||||
записи живёт в хранилище»; откуда взято число —
|
записи живёт в хранилище»; откуда взято число —
|
||||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||||
записка не узнала».
|
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
|
||||||
|
она осталась только этим числом.
|
||||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||||
обрыв начинает загрузку заново.
|
обрыв начинает загрузку заново.
|
||||||
@@ -358,8 +367,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
|||||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
|
||||||
или нет, тоже не решено.
|
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
|
||||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||||
@@ -374,12 +383,15 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
|||||||
паузы, а не замер
|
паузы, а не замер
|
||||||
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
|
([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` остаётся и развивается. Чем — дописывать
|
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||||
выкладке сегодня нет.
|
выкладке сегодня нет.
|
||||||
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
||||||
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
||||||
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
|
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
|
||||||
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
||||||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые
|
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
|
||||||
ключи `[yandex]` ловит конструктор распознавателя.
|
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
|
||||||
|
|
||||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||||
@@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
|||||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||||
|
|
||||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
|
||||||
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
|
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
|
||||||
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
|
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
|
||||||
|
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
|
||||||
|
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
|
||||||
|
которой не управляют. Причина в цене умолчания: поднявшись с
|
||||||
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||||
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||||
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||||
|
|
||||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
|
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
|
||||||
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
|
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
|
||||||
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
|
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
|
||||||
держит линтер. Правила действуют на новый код; переписывание существующего —
|
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
|
||||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
записи не осталось.
|
||||||
|
|
||||||
**Механизировано:** сверка изменённого шага схемы с
|
**Механизировано:** сверка изменённого шага схемы с
|
||||||
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
## Первичные ключи — ULID, не автоинкремент
|
## Первичные ключи — ULID, не автоинкремент
|
||||||
|
|
||||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||||
**приложением** в момент создания записи.
|
**приложением** в момент создания записи. Выдача монотонна внутри одной
|
||||||
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
|
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
|
||||||
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
|
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
|
||||||
хронологией не является: порядок берут по колонке времени с ключом.
|
времени мало.
|
||||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||||
записи сущности в логах.
|
записи сущности в логах.
|
||||||
- **Точка генерации и разбора одна**: создание — при вставке записи в
|
- **Точка генерации и разбора одна** — `internal/ident`: `New` выдаёт, `Parse`
|
||||||
репозитории, разбор — на входных границах. Самодельных генераторов по месту
|
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
|
||||||
вызова не заводим.
|
заводим.
|
||||||
|
|
||||||
## Канонический вид — lowercase
|
## Канонический вид — lowercase
|
||||||
|
|
||||||
@@ -47,31 +47,28 @@
|
|||||||
|
|
||||||
## Прочее
|
## Прочее
|
||||||
|
|
||||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||||
значения держит код.
|
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
|
||||||
*Расхождение:* перечни, по которым панель владельца правит запись руками,
|
панель владельца, и она вправе была завести значение, которого сервис не
|
||||||
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
|
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
|
||||||
значение, которого сервис не знает. Закрыты рубеж записи, причина её
|
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
|
||||||
остановки, вид текста, источник и исход события журнала. Цена названа: новое
|
покупателя.
|
||||||
значение любого из них потребует нового шага схемы, а применённый шаг не
|
|
||||||
переписывается. Прочие перечни остаются обычным `TEXT`.
|
|
||||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||||
вставка падает громко. Измерение длительности — не метка времени.
|
вставка падает громко. Измерение длительности — не метка времени.
|
||||||
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
|
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
|
||||||
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
|
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
|
||||||
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
|
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
|
||||||
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
|
срока захвата в константу.
|
||||||
разошедшийся вид молча обращает условие срока захвата в константу.
|
- Миграции — шаги `pressly/goose/v3` на Go
|
||||||
- Миграции — шаги PocketBase на Go
|
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
|
||||||
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
|
||||||
поля заводятся кодом. При изменении структуры обновляем схему
|
структуры обновляем схему [../database.md](../database.md) тем же изменением.
|
||||||
[../database.md](../database.md) тем же изменением.
|
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
|
||||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
|
||||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
постоянную истину или ложь — молча.
|
||||||
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
|
|
||||||
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
||||||
сравнение по неуникальному значению делает порядок обработки
|
сравнение по неуникальному значению делает порядок обработки
|
||||||
невоспроизводимым.
|
невоспроизводимым.
|
||||||
|
|||||||
+23
-16
@@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
|
|
||||||
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| сессии нет | 401 | `unauthorized` | «требуется вход» |
|
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
|
||||||
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
|
|
||||||
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||||||
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
|
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
|
||||||
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||||||
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||||||
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||||
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||||||
|
|
||||||
|
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
|
||||||
|
случаем: им был владелец панели, предъявивший собственный токен хранилища.
|
||||||
|
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
|
||||||
|
учётную запись заводит само.
|
||||||
|
|
||||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||||
@@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
`json-api-for-spa` 2026-08-15.
|
`json-api-for-spa` 2026-08-15.
|
||||||
|
|
||||||
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||||||
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
|
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
|
||||||
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
|
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
|
||||||
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
|
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
|
||||||
приходил бы телом библиотеки.
|
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
|
||||||
|
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
|
||||||
|
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
|
||||||
|
неоткуда.
|
||||||
|
|
||||||
### Разовый ответ и сохранённая диагностика
|
### Разовый ответ и сохранённая диагностика
|
||||||
|
|
||||||
@@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
|
|
||||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||||
полная ошибка остаётся в логах по идентификатору задачи.
|
полная ошибка остаётся в логах по идентификатору задачи.
|
||||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
|
||||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||||
и полезен. Но:
|
и полезен. Но:
|
||||||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||||||
@@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
числом рядом**: без этого непонятно, насколько сокращать.
|
числом рядом**: без этого непонятно, насколько сокращать.
|
||||||
|
|
||||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
|
||||||
машинного текста — эту часть правила держит спека `intake`.
|
машинного текста — эту часть правила держит спека `archive`.
|
||||||
|
|
||||||
## panic
|
## panic
|
||||||
|
|
||||||
@@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||||
это значения `error`.
|
это значения `error`.
|
||||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
|
||||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
|
||||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
|
||||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
|
||||||
шаге конвейера роняет процесс целиком.
|
роняет процесс целиком.
|
||||||
|
|
||||||
## Несколько ошибок
|
## Несколько ошибок
|
||||||
|
|
||||||
|
|||||||
@@ -78,10 +78,11 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||||
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
|
| Непроверенное возвращаемое значение ошибки | `.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` его не видит |
|
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||||
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
| Проверенный отказ не оборачивается в `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` |
|
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` |
|
||||||
|
|
||||||
### Структура и границы
|
### Структура и границы
|
||||||
@@ -90,8 +91,9 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||||
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||||
|
| Транспорты не знают адаптеров | `internal/archrules` → `TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
|
||||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||||
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
|
||||||
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||||
|
|
||||||
### Отмена и внешний собеседник
|
### Отмена и внешний собеседник
|
||||||
@@ -138,7 +140,7 @@
|
|||||||
|
|
||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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` |
|
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
|
||||||
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||||
@@ -188,11 +190,9 @@
|
|||||||
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||||
молчит на всём каталоге, и на подозрении база задаётся руками
|
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||||
(`task migrations BASE=<rev>`);
|
(`task migrations BASE=<rev>`);
|
||||||
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
|
||||||
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
|
||||||
есть роутер этого хранилища. Изъятие названо в
|
[../architecture.md](../architecture.md), «Слои и модель домена».
|
||||||
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
|
|
||||||
нет.
|
|
||||||
|
|
||||||
Отдельно названы **правила, чей подъём отклонён**:
|
Отдельно названы **правила, чей подъём отклонён**:
|
||||||
|
|
||||||
|
|||||||
@@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
|
|||||||
|
|
||||||
| Когда добавляем | Поля |
|
| Когда добавляем | Поля |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
|
||||||
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
||||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||||
| на запись об ошибке | `error` |
|
| на запись об ошибке | `error` |
|
||||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||||
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` |
|
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
|
||||||
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
||||||
|
|
||||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||||
@@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника
|
|||||||
## HTTP и проверка здоровья
|
## HTTP и проверка здоровья
|
||||||
|
|
||||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||||
`http.status_code`, `duration_ms`, `transport`.
|
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
|
||||||
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
||||||
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
||||||
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
||||||
@@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника
|
|||||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||||
`INFO` они не пишутся.
|
`INFO` они не пишутся.
|
||||||
|
|
||||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
Расхождения здесь больше нет: слой журналирования запросов свой —
|
||||||
`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
|
||||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
то есть при боевом `INFO` не пишутся вовсе.
|
||||||
|
|
||||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
|
||||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
|
||||||
по которому разбирают отказы, эта таблица не попадает.
|
|
||||||
|
|
||||||
## Безопасность: что не логируем
|
## Безопасность: что не логируем
|
||||||
|
|
||||||
@@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника
|
|||||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||||
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||||
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||||
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
|
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
|
||||||
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||||
там расширение приводится к перечню известных форматов. Остаток описан в
|
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||||
[../security.md](../security.md).
|
[../security.md](../security.md).
|
||||||
|
|||||||
@@ -74,15 +74,16 @@
|
|||||||
[webapp](../../openspec/specs/webapp/spec.md).
|
[webapp](../../openspec/specs/webapp/spec.md).
|
||||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||||
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||||
`404`; путь внутри корня в приложение не проваливается никогда. Корней
|
`404`; путь внутри корня в приложение не проваливается никогда. Корень
|
||||||
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
|
сегодня **один** — `/app/` у приложения, — плюс `/health` и `/metrics`
|
||||||
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
|
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
|
||||||
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
|
ушёл с собственным входом, два других — со встроенным хранилищем и его
|
||||||
вне корней. Приложение
|
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
|
||||||
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
|
||||||
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
|
||||||
нашим. Перечень корней сервису не описывают, а из него **порождают**
|
смены хранилища не изменились ни одним полем. Перечень корней сервису не
|
||||||
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча.
|
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
|
||||||
|
он разошёлся бы с ними молча.
|
||||||
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
||||||
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
||||||
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
||||||
|
|||||||
+276
-217
@@ -1,62 +1,118 @@
|
|||||||
# Схема хранилища
|
# Схема хранилища
|
||||||
|
|
||||||
Хранилище, коллекции, правило времени и идентификаторов.
|
База, таблицы, раскладка файлов, правило времени и идентификаторов.
|
||||||
|
|
||||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
|
||||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один —
|
||||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
|
||||||
CGO сборке не нужен.
|
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 знаков собственного алфавита.
|
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
|
||||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
|
||||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
|
||||||
|
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
|
||||||
|
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
|
||||||
|
считается несуществующей записью и до базы не доходит.
|
||||||
|
|
||||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
|
||||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
файла внутри него — `<ULID><расширение>`.
|
||||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
|
||||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
|
||||||
постоянную ложь молча.
|
|
||||||
|
|
||||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
**Время** — `TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
|
||||||
повторяем: перечень единых точек и их отсутствий держит
|
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
|
||||||
[architecture.md](architecture.md), «Единые точки проекта».
|
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
|
||||||
|
те, что пишет только сам сервис: своего типа времени у 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`
|
### `files`
|
||||||
|
|
||||||
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||||
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||||
считается — она существует только потому, что провайдер распознавания читает
|
считается — она существует только потому, что провайдер распознавания читает
|
||||||
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | ULID |
|
||||||
| `file` | file | Сам файл |
|
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
|
||||||
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
|
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
|
||||||
| `location` | select | `local` или `s3` |
|
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
|
||||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
| `size_bytes` | INTEGER | Размер копии в байтах |
|
||||||
| `size` | INTEGER | Размер в байтах |
|
|
||||||
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||||
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
| `created_at` | TEXT | Время |
|
||||||
|
|
||||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
|
||||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
|
||||||
|
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
|
||||||
|
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||||
|
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
|
||||||
|
|
||||||
### `audio_records`
|
### `audio_records`
|
||||||
|
|
||||||
@@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | ULID |
|
||||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
|
||||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
|
||||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||||
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||||
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||||
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты |
|
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
|
||||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
|
||||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
|
||||||
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
|
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
|
||||||
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
|
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||||
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
|
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
|
||||||
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом |
|
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
|
||||||
| `delay_time` | DATETIME | Не брать запись раньше этого времени |
|
| `delay_time` | TEXT | Не брать запись раньше этого времени |
|
||||||
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
||||||
| `original_file` | relation → `files` | Принятая копия |
|
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
|
||||||
| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату |
|
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
|
||||||
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи |
|
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
|
||||||
| `structure` | relation → `structures` | Структура реплик |
|
| `structure_id` | TEXT | Структура реплик |
|
||||||
| `recognition` | relation → `recognitions` | Попытка распознавания |
|
| `recognition_id` | TEXT | Попытка распознавания |
|
||||||
| `topics` | relation → `topics`, до 5 | Темы записи |
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
|
|
||||||
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
|
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
|
||||||
|
|
||||||
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
|
Индексов два. `idx_audio_records_acquire` — `(state, halted_at, created_at, id)`:
|
||||||
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
|
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
|
||||||
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и
|
`idx_audio_records_owner_page` — `(owner_id, created_at, id)`: под страницу
|
||||||
`(owner, state, halted_at)` под отбор тремя состояниями.
|
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
|
||||||
|
|
||||||
|
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
|
||||||
|
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
|
||||||
|
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
|
||||||
|
сканирования таблицы.
|
||||||
|
|
||||||
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
||||||
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
||||||
@@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||||
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||||
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||||
файла в хранилище и в журнал оно по-прежнему не идёт.
|
файла на диске и в журнал оно по-прежнему не идёт.
|
||||||
|
|
||||||
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||||
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||||
приёмом один раз; на файле — величины нынешней копии файла.
|
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
|
||||||
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы —
|
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
|
||||||
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому,
|
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
|
||||||
что показываются в списке, а список читается без содержимого. Решение владельца
|
а список читается без содержимого. Решение владельца от 2026-08-15.
|
||||||
от 2026-08-15.
|
|
||||||
|
|
||||||
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.**
|
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
|
||||||
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём.
|
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
|
||||||
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел
|
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
|
||||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||||
метаданными отвергается отказом и не заводится вовсе.
|
никто не читает.
|
||||||
|
|
||||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||||
@@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||||
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||||
признака, — и различие между ними перестало быть структурным. Рубеж при
|
признака, — и различие между ними перестало быть структурным.
|
||||||
остановке сохраняется, поэтому запись продолжает с места остановки.
|
|
||||||
|
|
||||||
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
||||||
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
||||||
справлялось ни с одной.
|
справлялось ни с одной.
|
||||||
|
|
||||||
|
### `record_topics`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Запись |
|
||||||
|
| `topic_id` | TEXT → `topics(id)` | Тема |
|
||||||
|
|
||||||
|
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
|
||||||
|
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
|
||||||
|
Число берётся у домена — то же самое, которое сервис объявляет приложению.
|
||||||
|
|
||||||
### `texts`
|
### `texts`
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `record` | relation → `audio_records` | Чья это расшифровка |
|
| `id` | TEXT PK | ULID |
|
||||||
| `kind` | select | `transcript` или `literary` |
|
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
|
||||||
| `contents` | editor | Сам текст |
|
| `kind` | TEXT | `transcript` или `literary` |
|
||||||
|
| `contents` | TEXT | Сам текст |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
||||||
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
||||||
@@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `record` | relation → `audio_records` | Чья это структура |
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
|
||||||
| `version` | INTEGER | Версия вида разбора |
|
| `version` | INTEGER | Версия вида разбора |
|
||||||
| `contents` | JSON | Реплики со временем |
|
| `contents` | TEXT | Реплики со временем, JSON |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||||
сохранённого ответа изменится раньше, чем архив пересчитают.
|
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||||
@@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `record` | relation → `audio_records` | Чья это попытка |
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
|
||||||
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
||||||
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
||||||
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
||||||
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
|
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
|
||||||
| `started_at`, `finished_at` | DATETIME | Границы операции |
|
| `started_at`, `finished_at` | TEXT | Границы операции |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
|
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
|
||||||
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
|
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
|
||||||
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
|
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
|
||||||
операции у провайдера не переспрашивается.
|
каждом опросе. Хранится он потому, что результат операции у провайдера не
|
||||||
|
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
|
||||||
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
|
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
|
||||||
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
|
||||||
|
|
||||||
### `record_events`
|
### `record_events`
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `record` | relation → `audio_records` | Чьё это событие |
|
| `id` | TEXT PK | ULID |
|
||||||
| `origin` | select | `pipeline` или `human` |
|
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
|
||||||
|
| `origin` | TEXT | `pipeline` или `human` |
|
||||||
| `step` | TEXT | Имя шага |
|
| `step` | TEXT | Имя шага |
|
||||||
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
|
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
|
||||||
| `outcome_text` | TEXT | Причина, если она есть |
|
| `outcome_text` | TEXT | Причина, если она есть |
|
||||||
| `duration_ms` | INTEGER | Сколько шаг занял |
|
| `duration_ms` | INTEGER | Сколько шаг занял |
|
||||||
|
| `created_at` | TEXT | Время |
|
||||||
|
|
||||||
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
||||||
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
||||||
двусмысленным.
|
двусмысленным.
|
||||||
|
|
||||||
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
|
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
|
||||||
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
||||||
что делать дальше.
|
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
|
||||||
|
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
|
||||||
|
журнала не осталось.
|
||||||
|
|
||||||
### `topics`
|
### `topics`
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `owner` | relation → `users` | Чей это словарь |
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
|
||||||
| `name` | TEXT | Название темы |
|
| `name` | TEXT | Название темы |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
||||||
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
|
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
|
||||||
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
|
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
|
||||||
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
|
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
|
||||||
платила вторым необратимым шагом схемы.
|
считающая темы, не платила вторым необратимым шагом схемы.
|
||||||
|
|
||||||
### Чего в схеме больше нет
|
### Чего в схеме больше нет
|
||||||
|
|
||||||
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
|
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
|
||||||
сервис на сервере остановлен, а прежние записи удалены решением владельца
|
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
|
||||||
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
|
«применённая миграция не переписывается», решением владельца от 2026-08-22:
|
||||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
|
||||||
|
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
|
||||||
|
годятся вовсе. Снятие кончается этим шагом.
|
||||||
|
|
||||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
|
||||||
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
|
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
|
||||||
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
|
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
|
||||||
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
|
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
|
||||||
некому, и обязательность переехала из приёма в схему — туда, где её держит
|
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
|
||||||
хранилище, а не договорённость.
|
одним новым шагом схемы.
|
||||||
|
|
||||||
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
|
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
|
||||||
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
|
причине:** их держал применённый шаг, которого больше не существует.
|
||||||
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
|
|
||||||
|
|
||||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
**Учётная запись с записями не удаляется**, и держит это схема обязательной
|
||||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
|
||||||
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
|
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
|
||||||
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
|
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
|
||||||
файловой записи равнялось праву скачать чужое аудио.
|
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
|
||||||
|
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
|
||||||
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
|
тупик до задачи про удаление записи.
|
||||||
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
|
||||||
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
|
|
||||||
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
|
||||||
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
|
||||||
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
|
|
||||||
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
|
|
||||||
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
|
|
||||||
вместо нашего отказа с причиной.
|
|
||||||
|
|
||||||
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
|
|
||||||
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
|
|
||||||
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
|
|
||||||
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
|
|
||||||
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`.
|
|
||||||
|
|
||||||
## Представление данных
|
## Представление данных
|
||||||
|
|
||||||
@@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||||
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||||
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
|
колонки шаг читает отдельным чтением.
|
||||||
же строки и читалась при каждом опросе очереди.
|
- **Файлы записи лежат подкаталогом на запись:**
|
||||||
- **Аудио лежит в раскладке хранилища:**
|
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
|
||||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
|
||||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
убирается целиком одним движением; плоский каталог, где копии различаются
|
||||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
|
||||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
|
||||||
каталог и бакет растут неограниченно.
|
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
|
||||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
растут неограниченно.
|
||||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
|
||||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||||
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
|
дочитан до конца без отказа. Строка о файле заводится **после** этого;
|
||||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
содержимое легло, а строка не сохранилась — уложенный файл убирается.
|
||||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
- **Файл отдаётся адресом приложения** —
|
||||||
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
|
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
|
||||||
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
|
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
|
||||||
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
|
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
|
||||||
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
|
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
|
||||||
означает «только владелец панели».
|
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
|
||||||
|
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
|
||||||
Правку и удаление умолчание библиотеки открывало владельцу записи
|
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
|
||||||
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
|
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
|
||||||
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
|
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
|
||||||
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
|
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
|
||||||
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
|
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
|
||||||
имени. Наш код читает и заводит запись мимо правил, панель работает
|
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
|
||||||
суперпользователем, своих экранов профиля сервис не заводит.
|
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
|
||||||
|
протухания захвата и отсутствию признака остановки; срок протухания выбирается
|
||||||
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
|
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
|
||||||
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
|
подставить его не может.
|
||||||
значение, которым его называет обратный прокси заголовком. По нему запись
|
|
||||||
ищется и по нему же заводится при первом обращении.
|
|
||||||
|
|
||||||
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
|
|
||||||
не обязан её приносить, а ключом она не служит. Уникальность почты держится
|
|
||||||
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
|
|
||||||
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
|
|
||||||
пустым ключом схема не примет вовсе.
|
|
||||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
|
||||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
|
||||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
|
||||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
|
||||||
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
|
||||||
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
|
||||||
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
|
||||||
его не может.
|
|
||||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
|
||||||
что условие проверяется тем же запросом, что и сам захват.
|
запросе правки, поэтому между проверкой и записью не остаётся окна.
|
||||||
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
|
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
|
||||||
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
|
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
|
||||||
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
|
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
|
||||||
перечень перестал расти с моделью. Правило правки и его серьёзность —
|
который компилируется молча и кладёт идентификатор файла в колонку текста.
|
||||||
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
|
Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и
|
||||||
|
`readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность —
|
||||||
|
инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила
|
||||||
`internal/archrules`.
|
`internal/archrules`.
|
||||||
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||||
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||||
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||||
по инварианту проекта не пишется в журнал и не считается в метрику.
|
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
|
||||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
|
||||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
|
||||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
полный URL объекта.
|
||||||
|
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
|
||||||
|
Отменять там нечего: операции местные и короткие, а единственное ожидание —
|
||||||
|
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
|
||||||
|
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
|
||||||
|
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
|
||||||
|
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
|
||||||
|
распознавания.
|
||||||
|
|
||||||
## Настройки с числовым значением
|
## Настройки с числовым значением
|
||||||
|
|
||||||
@@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||||
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
||||||
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
||||||
|
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
|
||||||
|
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
|
||||||
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||||
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||||
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||||
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||||
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||||
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||||
|
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
|
||||||
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
|
|
||||||
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
|
|
||||||
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
|
|
||||||
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
|
|
||||||
одновременно открытых карточек выбирают его целиком. Требование к контуру,
|
|
||||||
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
|
|
||||||
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
|
|
||||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||||
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
|
||||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
|
||||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||||
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
|
||||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||||
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
|
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
|
||||||
|
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
|
||||||
|
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
|
||||||
|
|
||||||
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
|
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
|
||||||
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
|
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
|
||||||
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
|
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
|
||||||
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
|
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
|
||||||
|
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
|
||||||
|
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||||
|
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
|
||||||
|
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
|
||||||
|
[security.md](security.md), «Периметр».
|
||||||
|
|
||||||
|
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
|
||||||
|
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
|
||||||
|
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
|
||||||
|
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
|
||||||
|
потолок времени на вход у провайдера и таймаут обмена кода.
|
||||||
|
|
||||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||||
@@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||||
подпадает: она снимает захват сама.
|
подпадает: она снимает захват сама.
|
||||||
|
|
||||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
|
||||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
запроса приёма, и назван дважды: объявленная длина судится заранее, а
|
||||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
|
||||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
|
||||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
|
||||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
целенаправленной нагрузке объявлена вне модели угроз.
|
||||||
нагрузке объявлена вне модели угроз.
|
|
||||||
|
|
||||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
|
||||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||||
файлов и объектов нет вовсе. Таймаутов у
|
|
||||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
|
||||||
|
|||||||
+11
-10
@@ -65,9 +65,9 @@ Telegram.
|
|||||||
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||||
имя, названное провайдером, — заводит строку при первом обращении под новым
|
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||||
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||||
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
|
сервис не решает никогда. Исключений у этого больше нет: панель администратора
|
||||||
решением про PocketBase: в панель администратора владелец входит своим
|
со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе
|
||||||
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
|
со встроенным хранилищем — своего входа сервис не ведёт вовсе.
|
||||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||||
обрабатываем.
|
обрабатываем.
|
||||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||||
@@ -117,10 +117,11 @@ Telegram.
|
|||||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||||
перестанет устраивать по цене или по качеству русской речи.
|
перестанет устраивать по цене или по качеству русской речи.
|
||||||
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
|
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
|
||||||
задача `pocketbase-storage` 2026-08-12
|
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
|
||||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
перевела её в стек; стеком — 2026-08-22, когда задача
|
||||||
держит хранилище, файлы и панель владельца. Схема и
|
`storage-without-pocketbase`
|
||||||
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
|
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
|
||||||
сервис по имени, названному Authelia; источником людей она при этом не
|
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
|
||||||
становится: заводит и проверяет их по-прежнему Authelia.
|
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
|
||||||
|
[database.md](database.md).
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
|||||||
|
|
||||||
## Записи
|
## Записи
|
||||||
|
|
||||||
|
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
|
||||||
|
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
|
||||||
|
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
|
||||||
|
сказано.
|
||||||
|
|
||||||
| Дата | Запись | О чём |
|
| Дата | Запись | О чём |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
|
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# PocketBase: умолчания, которые ломают штатный сценарий
|
# 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-storage` уже на своём коде. От
|
||||||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# PocketBase: что даёт панель администратора
|
# 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`: что панель показывает и
|
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
|
||||||
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
||||||
держать перевод хранилища в планах.
|
держать перевод хранилища в планах.
|
||||||
|
|||||||
+86
-18
@@ -72,11 +72,11 @@
|
|||||||
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||||||
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||||||
**отказывает, а не висит**;
|
**отказывает, а не висит**;
|
||||||
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и
|
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
|
||||||
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда
|
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
|
||||||
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит
|
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
|
||||||
только на успехе и только потому, что признак отказа от записи поставлен
|
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
|
||||||
руками: готовая раздача статики ставит его сама, своя — нет.
|
пишется дословно ни под каким корнем, включая корень приложения.
|
||||||
|
|
||||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||||
|
|
||||||
@@ -86,17 +86,20 @@
|
|||||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||||
успех молча.
|
успех молча.
|
||||||
|
|
||||||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
|
||||||
подпакетом `migrations`):
|
подпакетом `migrations`):
|
||||||
|
|
||||||
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
|
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
|
||||||
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
|
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
|
||||||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
|
||||||
|
**именами**: именованный параметр запроса и место назначения по имени, а не
|
||||||
|
позиция в списке;
|
||||||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||||
держатель захвата;
|
держатель захвата, и держатель узнаётся значением признака;
|
||||||
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
|
||||||
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
из единой точки ([database.md](database.md), «Представление данных»);
|
||||||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
|
||||||
|
целиком.
|
||||||
|
|
||||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||||
`adapter/metaviewer/ffmpeg`):
|
`adapter/metaviewer/ffmpeg`):
|
||||||
@@ -256,7 +259,7 @@
|
|||||||
|
|
||||||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||||||
колонку разом;
|
колонку разом;
|
||||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
- замена хранилища — любой её кусок;
|
||||||
- смена модели очереди: захват, повторы и воркеры разом;
|
- смена модели очереди: захват, повторы и воркеры разом;
|
||||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||||||
- изменение, убирающее или возвращающее вход приёма целиком.
|
- изменение, убирающее или возвращающее вход приёма целиком.
|
||||||
@@ -301,9 +304,10 @@ API и имя не откатываются обратной правкой по
|
|||||||
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
|
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
|
||||||
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
|
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
|
||||||
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||||
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит
|
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
|
||||||
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера
|
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
|
||||||
в прогоне нет, и находки этого рода остаются гипотезами.
|
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
|
||||||
|
как возврат недоступного проверке, а не как обычная работа.
|
||||||
|
|
||||||
**Перестали проверять сознательно:**
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
@@ -315,7 +319,8 @@ API и имя не откатываются обратной правкой по
|
|||||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
|
||||||
|
предметов осмотра нет с 2026-08-22.
|
||||||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||||
@@ -341,6 +346,69 @@ API и имя не откатываются обратной правкой по
|
|||||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
истории 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 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
|
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
|
||||||
|
|
||||||
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
|
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
|
||||||
|
|||||||
+103
-109
@@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис
|
|||||||
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
||||||
уходит содержимое записи» и «Что вне модели».
|
уходит содержимое записи» и «Что вне модели».
|
||||||
|
|
||||||
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
|
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
|
||||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
|
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
|
||||||
становится PocketBase, и вместе с ним на том же порту появляется панель по
|
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
|
||||||
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
|
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
|
||||||
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
|
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
|
||||||
панель через Authelia не пускает — у неё свой пароль суперпользователя.
|
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||||||
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
|
прокси нечего закрывать.
|
||||||
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
|
|
||||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
|
||||||
она вне модели («Что вне модели», строка про контур).
|
|
||||||
|
|
||||||
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает
|
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||||||
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу,
|
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||||||
что и `/_/`, а правило прокси написано на литерал и такой формы не видит.
|
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||||||
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают
|
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||||||
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
|
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||||||
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его
|
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||||||
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на
|
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||||||
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
|
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
|
||||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
|
||||||
|
|
||||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||||
@@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис
|
|||||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||||
человек.
|
человек.
|
||||||
|
|
||||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
|
||||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
|
||||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
|
||||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
|
||||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
|
||||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
|
||||||
соединения.
|
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
|
||||||
|
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
|
||||||
|
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
|
||||||
|
пришёл, решает адрес самого соединения.
|
||||||
|
|
||||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||||
@@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис
|
|||||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||||
и в панели владельца.
|
приложения.
|
||||||
|
|
||||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||||
|
|
||||||
@@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||||
путь не предполагался.
|
путь не предполагался.
|
||||||
|
|
||||||
- **Путь на диске** выбирает хранилище:
|
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
|
||||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||||||
|
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||||||
|
роняет заведение временного файла.
|
||||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||||
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||||
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||||
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||||
распознавания.
|
распознавания.
|
||||||
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
|
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
|
||||||
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
|
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
|
||||||
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
|
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
|
||||||
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
|
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
|
||||||
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
|
журнал, ни в метку метрики, ни в ответ.
|
||||||
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
|
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
|
||||||
чужим и со своим токеном файла ссылка отвечает «не найдено».
|
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
|
||||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
|
||||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
|
||||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
|
||||||
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
|
||||||
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
|
||||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
идёт расширение своим полем.
|
||||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
|
||||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
единственное, что защищает карточку записи, её текст и её файл сверх владения.
|
||||||
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
|
||||||
что защищает карточку записи и её текст.
|
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
|
||||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
|
||||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
|
||||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
|
||||||
есть доступны они только владельцу панели, — и коллекции, заведённые
|
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
|
||||||
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
|
|
||||||
поверхность хранилища. Коды, снятые прогоном, —
|
|
||||||
[database.md](database.md), «Коллекции», норма —
|
|
||||||
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
|
||||||
то, что заказано».
|
|
||||||
|
|
||||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||||
|
|
||||||
@@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||||
отзыв доступа у Authelia действует со следующего обращения.
|
отзыв доступа у Authelia действует со следующего обращения.
|
||||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||||||
панель. Протухший и негодный токен предъявленными не считаются.
|
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
приоритета осталось бы правилом без предмета.
|
||||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||||||
переписал бы себе ключ учётной записи на чужое имя.
|
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||||||
|
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||||||
|
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||||||
|
базе, а первый такой запрос с новым именем — записи в неё.
|
||||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||||||
схемы `202608220001`.
|
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
библиотечным правилом, не осталось.
|
||||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||||||
файловой ссылки не доходит.
|
|
||||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||||
2026-08-12.
|
2026-08-12.
|
||||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
больше нечего, и адресов этих не существует.
|
||||||
узнавание обходилось бы двумя запросами.
|
|
||||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||||
@@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||||
(`admin-stats-screen`).
|
(`admin-stats-screen`).
|
||||||
|
|
||||||
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
|
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
|
||||||
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
|
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
|
||||||
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
|
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
|
||||||
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
|
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
|
||||||
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
|
сервиса осталось одно — владение записью.
|
||||||
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
|
|
||||||
задан неверно: сброса в наборе команд нет.
|
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
|
||||||
|
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
|
||||||
|
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
|
||||||
|
поверхности, открытой в интернет, у неё нет вовсе.
|
||||||
|
|
||||||
## Что чувствительнее чего
|
## Что чувствительнее чего
|
||||||
|
|
||||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||||
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||||||
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
|
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
|
||||||
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||||||
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
|
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
|
||||||
|
полный текст речи — лежит файлом в подкаталоге записи**),
|
||||||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||||
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
|
||||||
наравне с записью — норму держит спека `storage`.
|
наравне с записью — норму держит спека `storage`.
|
||||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||||
Утечка оплачивается деньгами и доступом к бакету.
|
Утечка оплачивается деньгами и доступом к бакету.
|
||||||
@@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||||
открыта только владельцу.
|
открыта только владельцу.
|
||||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
заводить нечего.
|
||||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
|
||||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
|
||||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
|
||||||
читателю навсегда.
|
|
||||||
|
|
||||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||||
@@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||||
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||||||
по старому значению, покажет пустоту, и это не поломка.
|
по старому значению, покажет пустоту, и это не поломка.
|
||||||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||||
@@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||||
строки потребления текста не содержат.
|
строки потребления текста не содержат.
|
||||||
|
|
||||||
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
|
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||||||
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
|
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||||||
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
|
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||||||
каскада не имеют, поэтому удаление самой
|
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||||||
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
|
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||||||
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
|
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||||||
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
|
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||||||
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
|
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||||||
строки приложений — журнал событий, попытка распознавания вместе с её
|
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||||||
вложением, структура, тексты, — потом сама запись, потом её файлы. До
|
|
||||||
`delete-record` это единственный способ, и он ручной целиком.
|
`delete-record` это единственный способ, и он ручной целиком.
|
||||||
|
|||||||
@@ -12,18 +12,16 @@ require (
|
|||||||
github.com/aws/smithy-go v1.27.7
|
github.com/aws/smithy-go v1.27.7
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pressly/goose/v3 v3.27.3
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0
|
|
||||||
github.com/pocketbase/pocketbase v0.39.10
|
|
||||||
github.com/prometheus/client_golang v1.23.0
|
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
|
github.com/yandex-cloud/go-genproto v0.17.0
|
||||||
google.golang.org/grpc v1.82.1
|
google.golang.org/grpc v1.82.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
modernc.org/sqlite v1.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/feature/ec2/imds v1.18.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
@@ -40,41 +38,28 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/mfridman/interpolate v0.0.2 // 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/mattn/go-colorable v0.1.15 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.65.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
golang.org/x/crypto v0.54.0 // indirect
|
|
||||||
golang.org/x/image v0.45.0 // indirect
|
|
||||||
golang.org/x/net v0.57.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/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.41.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/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
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=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
@@ -47,147 +42,99 @@ 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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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-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/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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
||||||
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/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
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_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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
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.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
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/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
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=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
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 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
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 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
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.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
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/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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
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/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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 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/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-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
|
||||||
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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
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/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
@@ -195,11 +142,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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
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 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
@@ -210,8 +156,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/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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
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.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -220,8 +166,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,65 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
|
|
||||||
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
|
|
||||||
// молча. Так это уже и было — окружение проверок его не ставило, и всё
|
|
||||||
// разграничение проверялось на приложении, где архив сносится одним
|
|
||||||
// запросом.
|
|
||||||
GuardOwnerDeletion(app)
|
|
||||||
|
|
||||||
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,261 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
|
|
||||||
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
|
|
||||||
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
|
|
||||||
// допускает. Свой суффикс хранилище допишет само.
|
|
||||||
//
|
|
||||||
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
|
|
||||||
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
|
|
||||||
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
|
|
||||||
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*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)
|
|
||||||
record.Set("format", meta.Format)
|
|
||||||
record.Set("duration_ms", meta.DurationMs)
|
|
||||||
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
|
||||||
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
|
||||||
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
|
||||||
// бы доступным всякому вошедшему.
|
|
||||||
record.Set("owner", ownerID)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
// Отказ укладки называет имя файла — то самое, из которого строится
|
|
||||||
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
|
|
||||||
// ключ при чтении.
|
|
||||||
return nil, errors.New("failed to store file")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return &entity.File{
|
|
||||||
Id: record.Id,
|
|
||||||
Location: record.GetString("location"),
|
|
||||||
FileName: firstFileName(record),
|
|
||||||
Size: int64(record.GetInt("size")),
|
|
||||||
Format: record.GetString("format"),
|
|
||||||
DurationMs: int64(record.GetInt("duration_ms")),
|
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
|
||||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxProviderLoginLength — предел длины логина у провайдера.
|
|
||||||
//
|
|
||||||
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
|
|
||||||
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
|
|
||||||
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
|
|
||||||
// разных предела на соседних колонках одной записи разошлись бы молча.
|
|
||||||
const MaxProviderLoginLength = 255
|
|
||||||
|
|
||||||
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
|
|
||||||
// по той же причине: столько держит колонка имени в умолчании библиотеки.
|
|
||||||
const MaxDisplayNameLength = 255
|
|
||||||
|
|
||||||
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
|
||||||
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
|
|
||||||
// негодный ввод, и звать по нему учётную запись не надо.
|
|
||||||
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
|
||||||
|
|
||||||
// Identity — то, чем доверенный источник называет пришедшего.
|
|
||||||
//
|
|
||||||
// Логин — ключ, остальное берётся только при заведении записи.
|
|
||||||
type Identity struct {
|
|
||||||
Login string
|
|
||||||
Name string
|
|
||||||
Email string
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
|
|
||||||
// заводит её.
|
|
||||||
//
|
|
||||||
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
|
||||||
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
|
|
||||||
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
|
|
||||||
// дублировать вторым куском, либо вытаскивать задним числом.
|
|
||||||
//
|
|
||||||
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
|
||||||
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
|
||||||
// его работы.
|
|
||||||
//
|
|
||||||
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
|
|
||||||
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
|
|
||||||
// неизвестно, а угаданное правило склеило бы двух разных людей.
|
|
||||||
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
|
|
||||||
login, ok := AcceptProviderLogin(identity.Login)
|
|
||||||
if !ok {
|
|
||||||
return nil, false, ErrLoginNotAcceptable
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err = findUserByLogin(app, login)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
if record != nil {
|
|
||||||
return record, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
users, err := findCollection(app, migrations.UsersCollection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
record = core.NewRecord(users)
|
|
||||||
record.Set(migrations.ProviderLoginField, login)
|
|
||||||
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
|
|
||||||
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
|
|
||||||
// имя длиннее предела колонки отвергается проверкой записи, и человек с
|
|
||||||
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
|
|
||||||
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
|
|
||||||
// вправе отменять заведение записи.
|
|
||||||
record.Set("name", acceptDisplayName(identity.Name))
|
|
||||||
if email, ok := acceptEmail(identity.Email); ok {
|
|
||||||
record.SetEmail(email)
|
|
||||||
}
|
|
||||||
// Пароль записи обязателен при любом значении признака — это проверка самой
|
|
||||||
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
|
|
||||||
// потому что вход по паролю у коллекции выключен шагом схемы.
|
|
||||||
record.SetRandomPassword()
|
|
||||||
|
|
||||||
if err := app.Save(record); err != nil {
|
|
||||||
record, err = retryAfterConflict(app, login, record, err)
|
|
||||||
return record, record != nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return record, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
|
|
||||||
// разные, и исход у них разный.
|
|
||||||
//
|
|
||||||
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
|
|
||||||
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
|
|
||||||
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
|
|
||||||
//
|
|
||||||
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
|
|
||||||
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
|
|
||||||
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
|
|
||||||
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
|
|
||||||
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
|
|
||||||
// причины.
|
|
||||||
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
|
|
||||||
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
|
|
||||||
existing, err := findUserByLogin(app, login)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
|
|
||||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
record.SetEmail("")
|
|
||||||
if err := app.Save(record); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create user account without email: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
|
|
||||||
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
|
|
||||||
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
|
|
||||||
// аргумент.
|
|
||||||
func findUserByLogin(app core.App, login string) (*core.Record, error) {
|
|
||||||
records, err := app.FindRecordsByFilter(
|
|
||||||
migrations.UsersCollection,
|
|
||||||
migrations.ProviderLoginField+" = {:login}",
|
|
||||||
"", 1, 0,
|
|
||||||
map[string]any{"login": login},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
|
||||||
}
|
|
||||||
if len(records) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return records[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
|
|
||||||
//
|
|
||||||
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
|
|
||||||
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
|
|
||||||
const uniqueViolationCode = "validation_not_unique"
|
|
||||||
|
|
||||||
// isUniqueViolation говорит, отказала ли по названной колонке проверка
|
|
||||||
// **уникальности** — а не какая-нибудь другая.
|
|
||||||
//
|
|
||||||
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
|
|
||||||
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
|
|
||||||
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
|
|
||||||
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
|
|
||||||
// любой отказ проверки логина читался бы как гонка двух первых обращений.
|
|
||||||
func isUniqueViolation(err error, field string) bool {
|
|
||||||
var errs validation.Errors
|
|
||||||
if !errors.As(err, &errs) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldErr, ok := errs[field]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var object validation.ErrorObject
|
|
||||||
if !errors.As(fieldErr, &object) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return object.Code() == uniqueViolationCode
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptDisplayName приводит имя к годному для колонки значению.
|
|
||||||
//
|
|
||||||
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
|
|
||||||
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
|
|
||||||
// законно: имени у человека может не быть вовсе.
|
|
||||||
func acceptDisplayName(value string) string {
|
|
||||||
name := strings.TrimSpace(stripControls(value))
|
|
||||||
|
|
||||||
runes := []rune(name)
|
|
||||||
if len(runes) > MaxDisplayNameLength {
|
|
||||||
return string(runes[:MaxDisplayNameLength])
|
|
||||||
}
|
|
||||||
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
|
|
||||||
//
|
|
||||||
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
|
|
||||||
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
|
|
||||||
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
|
|
||||||
func acceptEmail(value string) (string, bool) {
|
|
||||||
email := strings.TrimSpace(value)
|
|
||||||
if email == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := is.EmailFormat.Validate(email); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return email, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
|
|
||||||
// им делать нечего.
|
|
||||||
func stripControls(value string) string {
|
|
||||||
return strings.Map(func(r rune) rune {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
|
|
||||||
// отвергает его.
|
|
||||||
//
|
|
||||||
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
|
|
||||||
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
|
|
||||||
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
|
|
||||||
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
|
|
||||||
//
|
|
||||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
|
||||||
// называет одного человека, а две записи о нём разошлись бы молча.
|
|
||||||
func AcceptProviderLogin(value string) (string, bool) {
|
|
||||||
login := strings.TrimSpace(value)
|
|
||||||
|
|
||||||
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
|
|
||||||
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
|
|
||||||
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range login {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return login, true
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Проверки узнавания: как учётная запись находится и как заводится.
|
|
||||||
|
|
||||||
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
|
|
||||||
// переписывает её.
|
|
||||||
//
|
|
||||||
// Не переписывает — половина требования, и она отдельная: перепись на каждом
|
|
||||||
// запросе означала бы запись в базу на каждый запрос, а правка имени у
|
|
||||||
// провайдера меняла бы карточку человека молча, посреди его работы.
|
|
||||||
func TestEnsureUserIsIdempotent(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
|
|
||||||
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
|
|
||||||
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
|
|
||||||
|
|
||||||
assert.Equal(t, 1, countUsers(t, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Одновременные первые обращения одним логином дают одну учётную запись.
|
|
||||||
//
|
|
||||||
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
|
|
||||||
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
|
|
||||||
// на одного человека — а архив разъехался бы между ними молча и склеить его было
|
|
||||||
// бы нечем.
|
|
||||||
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
const racers = 8
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
ids := make([]string, racers)
|
|
||||||
errs := make([]error, racers)
|
|
||||||
|
|
||||||
start := make(chan struct{})
|
|
||||||
for i := range racers {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start
|
|
||||||
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
|
|
||||||
errs[i] = err
|
|
||||||
if record != nil {
|
|
||||||
ids[i] = record.Id
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
close(start)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for i, err := range errs {
|
|
||||||
require.NoError(t, err, "обращение %d отказало", i)
|
|
||||||
}
|
|
||||||
for i, id := range ids {
|
|
||||||
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Занятая почта не мешает завести запись: она достаётся первому, а второй
|
|
||||||
// заводится без неё.
|
|
||||||
//
|
|
||||||
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
|
|
||||||
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
|
|
||||||
// бы никогда: повторный поиск по логину снова ничего не находит.
|
|
||||||
func TestEnsureUserWithTakenEmail(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotEqual(t, first.Id, second.Id)
|
|
||||||
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
|
|
||||||
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
|
|
||||||
assert.Equal(t, 2, countUsers(t, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вырожденный логин никого не узнаёт и ничего не заводит.
|
|
||||||
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
values := map[string]string{
|
|
||||||
"пустой": "",
|
|
||||||
"одни пробелы": " \t ",
|
|
||||||
"управляющий знак": "ali\x00ce",
|
|
||||||
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, value := range values {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: value})
|
|
||||||
|
|
||||||
assert.Nil(t, record)
|
|
||||||
require.ErrorIs(t, err, ErrLoginNotAcceptable)
|
|
||||||
assert.Equal(t, 0, countUsers(t, app))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
|
||||||
// называет одного человека, и две записи о нём разошлись бы молча.
|
|
||||||
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
first, _, err := EnsureUser(app, Identity{Login: "alice"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
second, _, err := EnsureUser(app, Identity{Login: " alice "})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, first.Id, second.Id)
|
|
||||||
assert.Equal(t, 1, countUsers(t, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сравнение точное: приведение регистра завело бы правило, которого у
|
|
||||||
// провайдера нет, — и склеило бы двух разных людей.
|
|
||||||
func TestEnsureUserComparesExactly(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotEqual(t, lower.Id, upper.Id)
|
|
||||||
assert.Equal(t, 2, countUsers(t, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Значение, похожее на условие отбора, ищется как значение, а не как часть
|
|
||||||
// запроса: оно уходит хранилищу параметром.
|
|
||||||
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotEqual(t, victim.Id, attacker.Id,
|
|
||||||
"значение изменило сам запрос и вернуло чужую учётную запись")
|
|
||||||
assert.Equal(t, 2, countUsers(t, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
func countUsers(t *testing.T, app core.App) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return len(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Признак заведения отличает первое обращение от всех следующих.
|
|
||||||
//
|
|
||||||
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
|
|
||||||
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
|
|
||||||
// нечем.
|
|
||||||
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
_, created, err := EnsureUser(app, Identity{Login: "alice"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, created, "первое обращение не назвалось заведением")
|
|
||||||
|
|
||||||
_, created, err = EnsureUser(app, Identity{Login: "alice"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, created, "второе обращение назвалось заведением")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
|
|
||||||
//
|
|
||||||
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
|
|
||||||
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
|
|
||||||
// заводилась никогда, а починить у себя он ничего не мог.
|
|
||||||
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
long := strings.Repeat("я", MaxDisplayNameLength+50)
|
|
||||||
|
|
||||||
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
|
|
||||||
require.NoError(t, err, "негодное имя отменило заведение записи")
|
|
||||||
require.True(t, created)
|
|
||||||
|
|
||||||
name := record.GetString("name")
|
|
||||||
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
|
|
||||||
assert.NotEmpty(t, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Управляющие знаки из имени убираются: значение приезжает заголовком.
|
|
||||||
func TestEnsureUserStripsControlsFromName(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "Карол", record.GetString("name"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
|
|
||||||
//
|
|
||||||
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
|
|
||||||
// ищет второго человека там, где сломан прокси.
|
|
||||||
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
|
|
||||||
require.NoError(t, err, "негодная почта отменила заведение записи")
|
|
||||||
require.True(t, created)
|
|
||||||
|
|
||||||
assert.Empty(t, record.Email())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
|
|
||||||
//
|
|
||||||
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
|
|
||||||
// хотя колонка приняла бы его.
|
|
||||||
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
login := strings.Repeat("я", MaxProviderLoginLength)
|
|
||||||
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: login})
|
|
||||||
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
|
|
||||||
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
|
|
||||||
|
|
||||||
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
|
|
||||||
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
|
|
||||||
// прямым сохранением.
|
|
||||||
//
|
|
||||||
// Правила доступа коллекции закрывают только путь снаружи; панель работает
|
|
||||||
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
|
|
||||||
// придёт с этим именем, и вернуть его было бы нечем.
|
|
||||||
func TestProviderLoginIsImmutable(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
record.Set(migrations.ProviderLoginField, "someone-else")
|
|
||||||
err = app.Save(record)
|
|
||||||
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
|
|
||||||
|
|
||||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
|
|
||||||
// а не запирает коллекцию целиком.
|
|
||||||
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
|
|
||||||
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
record.Set("name", "Новое")
|
|
||||||
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
|
|
||||||
|
|
||||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Новое", stored.GetString("name"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
|
|
||||||
// индекс почты.
|
|
||||||
//
|
|
||||||
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
|
|
||||||
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
|
|
||||||
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, name := range []string{"Первый", "Второй"} {
|
|
||||||
record := core.NewRecord(users)
|
|
||||||
record.Set("name", name)
|
|
||||||
record.SetRandomPassword()
|
|
||||||
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 2, countUsers(t, app))
|
|
||||||
}
|
|
||||||
@@ -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,106 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// up202608140001 заводит владельца записи.
|
|
||||||
//
|
|
||||||
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
|
|
||||||
// владельцем стояла существующая учётная запись, а не строка, похожая на её
|
|
||||||
// идентификатор.
|
|
||||||
//
|
|
||||||
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
|
|
||||||
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
|
|
||||||
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
|
|
||||||
// держит поэтому сам приём, а не схема.
|
|
||||||
//
|
|
||||||
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
|
|
||||||
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
|
|
||||||
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
|
|
||||||
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
|
|
||||||
// отвергается слоем приложения — `GuardOwnerDeletion`.
|
|
||||||
func up202608140001(app core.App) error {
|
|
||||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find users collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find jobs collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.Fields.Add(ownerField(users.Id))
|
|
||||||
|
|
||||||
if err := app.Save(jobs); err != nil {
|
|
||||||
return fmt.Errorf("failed to add owner to jobs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find files collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files.Fields.Add(ownerField(users.Id))
|
|
||||||
|
|
||||||
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
|
|
||||||
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
|
|
||||||
// строки разграничение закрыло бы метаданные задачи и оставило открытым
|
|
||||||
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
|
|
||||||
// файловой записи равнялось бы праву скачать чужое аудио.
|
|
||||||
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
|
|
||||||
|
|
||||||
if err := app.Save(files); err != nil {
|
|
||||||
return fmt.Errorf("failed to narrow files by owner: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerField собирает описание колонки владельца. Обе коллекции получают
|
|
||||||
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
|
|
||||||
func ownerField(usersCollectionID string) *core.RelationField {
|
|
||||||
return &core.RelationField{
|
|
||||||
Name: "owner",
|
|
||||||
CollectionId: usersCollectionID,
|
|
||||||
MaxSelect: 1,
|
|
||||||
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
|
|
||||||
// связь его не имеет по устройству, и запись не достаётся никому по
|
|
||||||
// недосмотру схемы.
|
|
||||||
Required: false,
|
|
||||||
// Удаление учётной записи не уносит её записи следом: сервис объявлен
|
|
||||||
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
|
|
||||||
CascadeDelete: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
|
|
||||||
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
|
|
||||||
func down202608140001(app core.App) error {
|
|
||||||
for _, name := range []string{JobsCollection, FilesCollection} {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find collection %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
field := collection.Fields.GetByName("owner")
|
|
||||||
if field == nil {
|
|
||||||
return errors.New("collection " + name + " has no owner field")
|
|
||||||
}
|
|
||||||
collection.Fields.RemoveById(field.GetId())
|
|
||||||
|
|
||||||
if name == FilesCollection {
|
|
||||||
collection.ViewRule = ptr(`@request.auth.id != ""`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.Save(collection); err != nil {
|
|
||||||
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// up202608140002 перестраивает модель вокруг аудиозаписи.
|
|
||||||
//
|
|
||||||
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
|
|
||||||
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
|
|
||||||
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
|
|
||||||
// того же понятия.
|
|
||||||
//
|
|
||||||
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
|
|
||||||
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
|
|
||||||
// приложения, и только потом ссылки дописываются.
|
|
||||||
//
|
|
||||||
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
|
|
||||||
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
|
|
||||||
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
|
|
||||||
// впрок, а норма проекта велит держать эту поверхность закрытой.
|
|
||||||
func up202608140002(app core.App) error {
|
|
||||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find users collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find files collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Формат и длительность у копии: по ним видно, чем запись была, не открывая
|
|
||||||
// её. Расширение наружу выходит только приведённым к перечню известных.
|
|
||||||
files.Fields.Add(
|
|
||||||
&core.TextField{Name: "format"},
|
|
||||||
&core.NumberField{Name: "duration_ms", OnlyInt: true},
|
|
||||||
)
|
|
||||||
if err := app.Save(files); err != nil {
|
|
||||||
return fmt.Errorf("failed to extend files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
topics, err := createTopics(app, users.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
texts, err := createTexts(app, records.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
structures, err := createStructures(app, records.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
recognitions, err := createRecognitions(app, records.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := createRecordEvents(app, records.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
|
|
||||||
records.Fields.Add(
|
|
||||||
&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
|
|
||||||
&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
|
|
||||||
&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
|
|
||||||
&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
|
|
||||||
)
|
|
||||||
if err := app.Save(records); err != nil {
|
|
||||||
return fmt.Errorf("failed to link audio records to their appendices: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find jobs collection: %w", err)
|
|
||||||
}
|
|
||||||
if err := app.Delete(jobs); err != nil {
|
|
||||||
return fmt.Errorf("failed to drop the former jobs collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAudioRecords заводит центральную сущность.
|
|
||||||
//
|
|
||||||
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
|
|
||||||
// свой результат, и исходник остаётся доступным после того, как запись прошла
|
|
||||||
// конвейер.
|
|
||||||
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
|
|
||||||
records := core.NewBaseCollection(RecordsCollection)
|
|
||||||
records.Fields.Add(
|
|
||||||
ownerField(usersID),
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "source",
|
|
||||||
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
// Заголовок и краткое описание читаются вместе со списком, сотней штук
|
|
||||||
// разом, и потому лежат колонками записи, а не строками текстов.
|
|
||||||
&core.TextField{Name: "title"},
|
|
||||||
&core.TextField{Name: "brief"},
|
|
||||||
// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
|
|
||||||
// должна попасть в выборку с рубежом, которого конвейер не знает.
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "state",
|
|
||||||
Values: entity.AllStates(),
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
|
|
||||||
// и возвратом записи в работу; откладывание опроса его не двигает.
|
|
||||||
&core.DateField{Name: "state_entered_at"},
|
|
||||||
// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
|
|
||||||
// признака продолжает работу с места остановки.
|
|
||||||
&core.DateField{Name: "halted_at"},
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "halt_reason",
|
|
||||||
Values: entity.AllHaltReasons(),
|
|
||||||
MaxSelect: 1,
|
|
||||||
},
|
|
||||||
&core.TextField{Name: "error_text"},
|
|
||||||
// Признак **этого** захвата: значение уникально для каждого захвата, и
|
|
||||||
// запись результата условна по нему, а не по занятости записи.
|
|
||||||
&core.TextField{Name: "acquisition_id"},
|
|
||||||
// Срок протухания захвата приезжает с рубежом и пишется числом при самом
|
|
||||||
// захвате: воркер не привязан к шагу и вывести срок из себя не может.
|
|
||||||
&core.DateField{Name: "acquire_expires_at"},
|
|
||||||
&core.DateField{Name: "delay_time"},
|
|
||||||
// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
|
|
||||||
// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
|
|
||||||
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
|
|
||||||
&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
|
|
||||||
&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
|
|
||||||
&core.RelationField{
|
|
||||||
Name: "topics",
|
|
||||||
CollectionId: topicsID,
|
|
||||||
MaxSelect: entity.MaxTopicsPerRecord,
|
|
||||||
},
|
|
||||||
&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},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
|
|
||||||
// захвата — индекс снимает полный перебор.
|
|
||||||
records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")
|
|
||||||
|
|
||||||
if err := app.Save(records); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create audio records: %w", err)
|
|
||||||
}
|
|
||||||
return records, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
|
|
||||||
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
|
||||||
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
|
||||||
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
|
|
||||||
texts := core.NewBaseCollection(TextsCollection)
|
|
||||||
texts.Fields.Add(
|
|
||||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "kind",
|
|
||||||
Values: entity.AllTextKinds(),
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
|
|
||||||
// зовут формат файла, и третий смысл у одного слова развёл бы по разным
|
|
||||||
// вещам вид текста и формат копии.
|
|
||||||
&core.EditorField{Name: "contents"},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")
|
|
||||||
|
|
||||||
if err := app.Save(texts); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create texts: %w", err)
|
|
||||||
}
|
|
||||||
return texts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createStructures заводит структуру реплик. Номер версии нужен потому, что
|
|
||||||
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
|
|
||||||
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
|
|
||||||
structures := core.NewBaseCollection(StructuresCollection)
|
|
||||||
structures.Fields.Add(
|
|
||||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
|
||||||
&core.NumberField{Name: "version", OnlyInt: true, Required: true},
|
|
||||||
&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
structures.AddIndex("idx_structures_record_version", true, "record, version", "")
|
|
||||||
|
|
||||||
if err := app.Save(structures); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create structures: %w", err)
|
|
||||||
}
|
|
||||||
return structures, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRecognitions заводит попытку распознавания у внешнего провайдера.
|
|
||||||
//
|
|
||||||
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
|
|
||||||
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
|
||||||
// многочасовую запись ехал бы в память при каждом опросе.
|
|
||||||
//
|
|
||||||
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
|
|
||||||
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
|
||||||
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
|
|
||||||
recognitions := core.NewBaseCollection(RecognitionsCollection)
|
|
||||||
payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
|
|
||||||
payload.Protected = true
|
|
||||||
|
|
||||||
recognitions.Fields.Add(
|
|
||||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
|
||||||
&core.TextField{Name: "provider", Required: true},
|
|
||||||
&core.TextField{Name: "model"},
|
|
||||||
// Идентификатор операции у провайдера — самое провайдерское, что есть в
|
|
||||||
// модели, и живёт он здесь, а не колонкой записи.
|
|
||||||
&core.TextField{Name: "external_id"},
|
|
||||||
// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
|
|
||||||
// файлом записи не считается: другой провайдер её не потребует.
|
|
||||||
&core.TextField{Name: "source_uri"},
|
|
||||||
payload,
|
|
||||||
&core.DateField{Name: "started_at"},
|
|
||||||
&core.DateField{Name: "finished_at"},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Save(recognitions); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create recognitions: %w", err)
|
|
||||||
}
|
|
||||||
return recognitions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRecordEvents заводит журнал событий записи.
|
|
||||||
//
|
|
||||||
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
|
|
||||||
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
|
|
||||||
// сделали бы инвариант двусмысленным.
|
|
||||||
func createRecordEvents(app core.App, recordsID string) error {
|
|
||||||
events := core.NewBaseCollection(RecordEventsCollection)
|
|
||||||
events.Fields.Add(
|
|
||||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "origin",
|
|
||||||
Values: entity.AllEventOrigins(),
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
&core.TextField{Name: "step"},
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "outcome",
|
|
||||||
Values: entity.AllEventOutcomes(),
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
&core.TextField{Name: "outcome_text"},
|
|
||||||
&core.NumberField{Name: "duration_ms", OnlyInt: true},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
)
|
|
||||||
events.AddIndex("idx_record_events_record", false, "record", "")
|
|
||||||
|
|
||||||
if err := app.Save(events); err != nil {
|
|
||||||
return fmt.Errorf("failed to create record events: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
|
|
||||||
// словарь свой у каждого человека, и общий показал бы одному темы другого.
|
|
||||||
func createTopics(app core.App, usersID string) (*core.Collection, error) {
|
|
||||||
topics := core.NewBaseCollection(TopicsCollection)
|
|
||||||
topics.Fields.Add(
|
|
||||||
&core.RelationField{
|
|
||||||
Name: "owner",
|
|
||||||
CollectionId: usersID,
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
&core.TextField{Name: "name", Required: true},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")
|
|
||||||
|
|
||||||
if err := app.Save(topics); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create topics: %w", err)
|
|
||||||
}
|
|
||||||
return topics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
|
|
||||||
// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
|
|
||||||
structureContentsMaxSize = 16 << 20
|
|
||||||
// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
|
|
||||||
// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
|
|
||||||
// разбор говорящих.
|
|
||||||
recognitionPayloadMaxSize = 256 << 20
|
|
||||||
)
|
|
||||||
|
|
||||||
// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
|
|
||||||
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
|
|
||||||
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.
|
|
||||||
|
|
||||||
// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
|
|
||||||
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
|
|
||||||
// вторым домом для понятия, которого больше нет.
|
|
||||||
func down202608140002(app core.App) error {
|
|
||||||
// Порядок обратный порядку заведения: приложения ссылаются на запись.
|
|
||||||
order := []string{
|
|
||||||
RecordEventsCollection,
|
|
||||||
RecognitionsCollection,
|
|
||||||
StructuresCollection,
|
|
||||||
TextsCollection,
|
|
||||||
RecordsCollection,
|
|
||||||
TopicsCollection,
|
|
||||||
}
|
|
||||||
for _, name := range order {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := app.Delete(collection); err != nil {
|
|
||||||
return fmt.Errorf("failed to drop %s: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
|
|
||||||
//
|
|
||||||
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
|
|
||||||
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
|
|
||||||
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
|
|
||||||
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
|
|
||||||
// не договорённость. Разница не косметическая: пока обязательность жила в
|
|
||||||
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
|
|
||||||
// денег на распознавание и не доставалась потом никому.
|
|
||||||
//
|
|
||||||
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
|
|
||||||
// держит обязательность связи проверкой записи при сохранении, а не ограничением
|
|
||||||
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
|
|
||||||
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
|
|
||||||
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
|
|
||||||
// прогон самого шага на копии этого не показывает.
|
|
||||||
//
|
|
||||||
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
|
|
||||||
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
|
|
||||||
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
|
|
||||||
// с проверки данных, а не с прогона шага.
|
|
||||||
func up202608140003(app core.App) error {
|
|
||||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
|
||||||
if err := setOwnerRequired(app, name, true); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// down202608140003 возвращает колонке необязательность. Записей это не касается:
|
|
||||||
// пустых значений среди них нет, а появиться им теперь неоткуда.
|
|
||||||
func down202608140003(app core.App) error {
|
|
||||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
|
||||||
if err := setOwnerRequired(app, name, false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOwnerRequired правит признак обязательности у колонки владельца одной
|
|
||||||
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
|
|
||||||
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
|
|
||||||
// узнает.
|
|
||||||
func setOwnerRequired(app core.App, collectionName string, required bool) error {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(collectionName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
field := collection.Fields.GetByName("owner")
|
|
||||||
if field == nil {
|
|
||||||
return errors.New("collection " + collectionName + " has no owner field")
|
|
||||||
}
|
|
||||||
|
|
||||||
relation, ok := field.(*core.RelationField)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("owner field of collection " + collectionName + " is not a relation")
|
|
||||||
}
|
|
||||||
|
|
||||||
relation.Required = required
|
|
||||||
|
|
||||||
if err := app.Save(collection); err != nil {
|
|
||||||
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// up202608150001 заводит у аудиозаписи три колонки, которые показывает список
|
|
||||||
// приложения: имя файла, данное отправителем, длительность и размер принятого.
|
|
||||||
//
|
|
||||||
// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага
|
|
||||||
// вместо одного стоили бы трёх необратимых решений там, где хватает одного.
|
|
||||||
//
|
|
||||||
// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт
|
|
||||||
// название, которое дал человек либо посчитала языковая модель; имя файла — то,
|
|
||||||
// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
|
|
||||||
// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы
|
|
||||||
// неоткуда.
|
|
||||||
//
|
|
||||||
// **Длительность и размер дублируют строку файла, и это решение владельца от
|
|
||||||
// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит
|
|
||||||
// снимок принятого, взятый приёмом один раз, на файле — величины той копии,
|
|
||||||
// которой файл является сейчас. Расхождение — не поломка, а разные вопросы;
|
|
||||||
// норму держит capability `storage`.
|
|
||||||
//
|
|
||||||
// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против
|
|
||||||
// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит
|
|
||||||
// ни компилятор, ни гейт — оба конца числа.
|
|
||||||
func up202608150001(app core.App) error {
|
|
||||||
records, err := app.FindCollectionByNameOrId(RecordsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
records.Fields.Add(
|
|
||||||
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому
|
|
||||||
// приём режет его по пределу и убирает управляющие знаки прежде, чем
|
|
||||||
// сохранить. Схема держит потолок вторым рубежом: значение сверх него
|
|
||||||
// отвергается хранилищем, а не доезжает до экрана.
|
|
||||||
&core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen},
|
|
||||||
// Длительность и размер принятого. «Неизвестно» колонки не выражают:
|
|
||||||
// числовая колонка хранилища пустого значения не держит, пустое кладётся
|
|
||||||
// нулём. Обе ставит приём и ставит всегда — запись с непрочитанными
|
|
||||||
// метаданными отвергается отказом и не заводится. Решение владельца
|
|
||||||
// 2026-08-15.
|
|
||||||
&core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)},
|
|
||||||
&core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Индекс под ленту приложения. Единственный прежний индекс — по рубежу и
|
|
||||||
// признаку остановки — заведён под захват воркера и выборке владельца не
|
|
||||||
// помогает ничем: страница сканирует таблицу целиком и досортировывает
|
|
||||||
// результат во временном дереве.
|
|
||||||
//
|
|
||||||
// Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч —
|
|
||||||
// сорокакратный — растит время одной страницы владельца в двадцать-тридцать
|
|
||||||
// раз, хотя записей у него всё те же сорок. Цена растёт с **чужими**
|
|
||||||
// записями, потому что сервис объявлен архивом и хранит их бессрочно.
|
|
||||||
//
|
|
||||||
// Порядок колонок повторяет порядок выборки: сужение по владельцу, затем
|
|
||||||
// сортировка «новыми сверху» полным ключом.
|
|
||||||
records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "")
|
|
||||||
// Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком
|
|
||||||
// остановки — своим индексом, потому что ведущей колонкой здесь владелец.
|
|
||||||
records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "")
|
|
||||||
|
|
||||||
if err := app.Save(records); err != nil {
|
|
||||||
return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// down202608150001 снимает три колонки. Данные в них при этом теряются, и
|
|
||||||
// восстановить их неоткуда: имя файла отправителя нигде больше не хранится.
|
|
||||||
func down202608150001(app core.App) error {
|
|
||||||
records, err := app.FindCollectionByNameOrId(RecordsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} {
|
|
||||||
records.Fields.RemoveByName(name)
|
|
||||||
}
|
|
||||||
records.RemoveIndex("idx_audio_records_owner_feed")
|
|
||||||
records.RemoveIndex("idx_audio_records_owner_state")
|
|
||||||
|
|
||||||
if err := app.Save(records); err != nil {
|
|
||||||
return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
|
|
||||||
// человека у провайдера, тот самый, которым его называет обратный прокси.
|
|
||||||
//
|
|
||||||
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
|
|
||||||
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
|
|
||||||
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
|
|
||||||
const ProviderLoginField = "provider_login"
|
|
||||||
|
|
||||||
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
|
|
||||||
const providerLoginIndex = "idx_users_provider_login"
|
|
||||||
|
|
||||||
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
|
|
||||||
// который называет доверенный источник.
|
|
||||||
//
|
|
||||||
// Три части, и каждая закрывает своё.
|
|
||||||
//
|
|
||||||
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
|
|
||||||
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
|
|
||||||
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
|
|
||||||
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
|
|
||||||
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
|
|
||||||
// бы чужой записи.
|
|
||||||
//
|
|
||||||
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
|
|
||||||
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
|
|
||||||
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
|
|
||||||
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
|
|
||||||
// обязательным при любом значении признака — ему ставится случайный, употребить
|
|
||||||
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
|
|
||||||
//
|
|
||||||
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
|
|
||||||
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
|
|
||||||
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
|
||||||
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
|
|
||||||
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
|
|
||||||
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
|
|
||||||
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
|
|
||||||
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
|
|
||||||
// Правила снимаются в пустое, что у хранилища означает «только владелец
|
|
||||||
// панели»; наш код читает и заводит запись мимо правил, панель работает
|
|
||||||
// суперпользователем, своих экранов профиля сервис не заводит.
|
|
||||||
func up202608220001(app core.App) error {
|
|
||||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find users collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
users.Fields.Add(&core.TextField{
|
|
||||||
Name: ProviderLoginField,
|
|
||||||
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
|
|
||||||
// имени не бывает, а колонка без предела принимала бы килобайты,
|
|
||||||
// пришедшие заголовком.
|
|
||||||
Max: 255,
|
|
||||||
})
|
|
||||||
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
|
|
||||||
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
|
|
||||||
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
|
|
||||||
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
|
|
||||||
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
|
|
||||||
// значения, а не индекс.
|
|
||||||
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
|
|
||||||
|
|
||||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("users collection has no email field")
|
|
||||||
}
|
|
||||||
email.Required = false
|
|
||||||
|
|
||||||
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
|
|
||||||
// к конфигу при подъёме, и обменивать код не на что.
|
|
||||||
users.OAuth2.Enabled = false
|
|
||||||
users.OAuth2.Providers = nil
|
|
||||||
|
|
||||||
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
|
|
||||||
users.ListRule = nil
|
|
||||||
users.ViewRule = nil
|
|
||||||
users.CreateRule = nil
|
|
||||||
users.UpdateRule = nil
|
|
||||||
users.DeleteRule = nil
|
|
||||||
|
|
||||||
if err := app.Save(users); err != nil {
|
|
||||||
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// down202608220001 убирает ключ учётной записи и возвращает обязательность
|
|
||||||
// почты.
|
|
||||||
//
|
|
||||||
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
|
|
||||||
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
|
|
||||||
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
|
|
||||||
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
|
|
||||||
// правка и переписывает.
|
|
||||||
func down202608220001(app core.App) error {
|
|
||||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find users collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
users.RemoveIndex(providerLoginIndex)
|
|
||||||
users.Fields.RemoveByName(ProviderLoginField)
|
|
||||||
|
|
||||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("users collection has no email field")
|
|
||||||
}
|
|
||||||
email.Required = true
|
|
||||||
|
|
||||||
if err := app.Save(users); err != nil {
|
|
||||||
return fmt.Errorf("failed to restore users collection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,57 +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 — прежняя коллекция задач. Шаг 202608140002 её удаляет;
|
|
||||||
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
|
|
||||||
// применённый шаг не переписывается.
|
|
||||||
JobsCollection = "transcribe_jobs"
|
|
||||||
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
|
|
||||||
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
|
|
||||||
// константе имён, запросу захвата, правилам панели и запрету удаления.
|
|
||||||
RecordsCollection = "audio_records"
|
|
||||||
TextsCollection = "texts"
|
|
||||||
StructuresCollection = "structures"
|
|
||||||
RecognitionsCollection = "recognitions"
|
|
||||||
RecordEventsCollection = "record_events"
|
|
||||||
TopicsCollection = "topics"
|
|
||||||
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
|
||||||
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
|
||||||
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
|
||||||
UsersCollection = "users"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
|
||||||
// `apis.Serve` прежде, чем поднять сервер.
|
|
||||||
func init() {
|
|
||||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
|
||||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
|
||||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
|
||||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
|
||||||
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
|
|
||||||
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
|
|
||||||
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
|
||||||
// аудиозаписи, их файлы либо темы её словаря.
|
|
||||||
//
|
|
||||||
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
|
||||||
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
|
||||||
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
|
|
||||||
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
|
|
||||||
// никому — архив человека исчез бы молча и восстановлению не подлежал:
|
|
||||||
// прежнего владельца не остаётся нигде.
|
|
||||||
//
|
|
||||||
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
|
|
||||||
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
|
|
||||||
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
|
|
||||||
//
|
|
||||||
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
|
|
||||||
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
|
|
||||||
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
|
||||||
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
|
||||||
//
|
|
||||||
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
|
|
||||||
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
|
|
||||||
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
|
|
||||||
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
|
|
||||||
// аудио становилось бы ничьим.
|
|
||||||
func GuardOwnerDeletion(app core.App) {
|
|
||||||
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
count, err := countOwned(e.App, e.Record.Id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
|
|
||||||
// наружу только `*router.ApiError`, а всякую другую подменяет своим
|
|
||||||
// сообщением — «убедитесь, что запись не участвует в обязательной
|
|
||||||
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
|
|
||||||
// единственная обязательная связь у задачи — файл, и владелец панели,
|
|
||||||
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
|
|
||||||
// ровно то необратимое, ради предотвращения чего страж и заведён.
|
|
||||||
//
|
|
||||||
// Число в отказе — не содержимое записей, а их счёт: он говорит
|
|
||||||
// владельцу панели, почему удаление не прошло, и не выносит наружу
|
|
||||||
// ничего о самих записях.
|
|
||||||
return router.NewBadRequestError(fmt.Sprintf(
|
|
||||||
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
|
|
||||||
count,
|
|
||||||
), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
|
|
||||||
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
|
|
||||||
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
|
|
||||||
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
|
|
||||||
// панели пойдёт удалять записи руками.
|
|
||||||
//
|
|
||||||
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
|
|
||||||
var ownedCollections = []string{
|
|
||||||
migrations.RecordsCollection,
|
|
||||||
migrations.FilesCollection,
|
|
||||||
migrations.TopicsCollection,
|
|
||||||
}
|
|
||||||
|
|
||||||
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
|
|
||||||
// с колонкой владельца.
|
|
||||||
func countOwned(app core.App, ownerID string) (int64, error) {
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
for _, collection := range ownedCollections {
|
|
||||||
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
|
||||||
}
|
|
||||||
total += count
|
|
||||||
}
|
|
||||||
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Страж удаления учётной записи — единственное, что стоит между владельцем
|
|
||||||
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
|
|
||||||
// хранилище снимает ссылку и сохраняет запись без проверок.
|
|
||||||
|
|
||||||
func newAccount(t *testing.T, app core.App) *core.Record {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
record := core.NewRecord(users)
|
|
||||||
record.Set("email", uuid.NewString()+"@example.test")
|
|
||||||
record.Set("verified", true)
|
|
||||||
record.Set("password", uuid.NewString())
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRecordOf заводит аудиозапись названного владельца.
|
|
||||||
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
record := &entity.AudioRecord{
|
|
||||||
State: entity.StateUploaded,
|
|
||||||
StateEnteredAt: clock.Now(),
|
|
||||||
Source: entity.SourceApi,
|
|
||||||
OwnerID: ownerID,
|
|
||||||
}
|
|
||||||
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
|
|
||||||
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
|
|
||||||
// владелец панели пойдёт удалять записи руками.
|
|
||||||
func TestGuardOwnerDeletion(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
account := newAccount(t, app)
|
|
||||||
record := newRecordOf(t, app, account.Id)
|
|
||||||
|
|
||||||
err := app.Delete(account)
|
|
||||||
require.Error(t, err, "учётная запись с архивом не удаляется")
|
|
||||||
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
|
|
||||||
|
|
||||||
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
|
||||||
require.NoError(t, err, "запись на месте")
|
|
||||||
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
|
||||||
// запись, а тема живёт в словаре человека.
|
|
||||||
func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) {
|
|
||||||
cases := map[string]func(t *testing.T, app core.App, ownerID string){
|
|
||||||
"аудиозапись": func(t *testing.T, app core.App, ownerID string) {
|
|
||||||
newRecordOf(t, app, ownerID)
|
|
||||||
},
|
|
||||||
"один файл без записи": func(t *testing.T, app core.App, ownerID string) {
|
|
||||||
repo := NewFileRepository(app)
|
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
|
||||||
|
|
||||||
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
},
|
|
||||||
"одна тема словаря": func(t *testing.T, app core.App, ownerID string) {
|
|
||||||
topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
topic := core.NewRecord(topics)
|
|
||||||
topic.Set("owner", ownerID)
|
|
||||||
topic.Set("name", "личная тема")
|
|
||||||
require.NoError(t, app.Save(topic))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, own := range cases {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
account := newAccount(t, app)
|
|
||||||
own(t, app, account.Id)
|
|
||||||
|
|
||||||
err := app.Delete(account)
|
|
||||||
require.Error(t, err, "учётная запись с этим добром не удаляется")
|
|
||||||
assert.Contains(t, err.Error(), "остались записи",
|
|
||||||
"отказ наш, а не подсказка библиотеки про обязательную связь")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Учётная запись, за которой ничего не числится, удаляется штатно: страж
|
|
||||||
// заведён против потери архива, а не против удаления вообще.
|
|
||||||
func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
account := newAccount(t, app)
|
|
||||||
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Колонка владельца пустого значения не принимает и умолчания не имеет:
|
|
||||||
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
|
|
||||||
// конвейером, ни рукой в панели.
|
|
||||||
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
field := collection.Fields.GetByName("owner")
|
|
||||||
require.NotNil(t, field, "колонка владельца заведена")
|
|
||||||
|
|
||||||
relation, ok := field.(*core.RelationField)
|
|
||||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
|
||||||
assert.True(t, relation.Required, "пустое значение колонка не принимает")
|
|
||||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
|
|
||||||
// только объявляет колонку обязательной.
|
|
||||||
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
record := &entity.AudioRecord{
|
|
||||||
State: entity.StateUploaded,
|
|
||||||
StateEnteredAt: clock.Now(),
|
|
||||||
Source: entity.SourceApi,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Error(t, NewAudioRecordRepository(app).Create(record),
|
|
||||||
"ничья запись в хранилище не ложится")
|
|
||||||
}
|
|
||||||
|
|
||||||
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
|
|
||||||
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
repo := NewFileRepository(app)
|
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
|
||||||
|
|
||||||
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
|
||||||
require.Error(t, err, "ничей файл в хранилище не ложится")
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
|
|
||||||
// из кода.
|
|
||||||
//
|
|
||||||
// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
|
|
||||||
// она и покупалась, остановленная запись возвращается в работу снятием признака.
|
|
||||||
// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
|
|
||||||
// «вернувший запись в работу», получил бы запись с прежним признаком захвата
|
|
||||||
// (захвату она не выдастся до конца срока), с числом отказов на пределе
|
|
||||||
// (остановится от первого же отказа) и со старым временем входа в рубеж
|
|
||||||
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
|
|
||||||
// неоткуда.
|
|
||||||
//
|
|
||||||
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
|
|
||||||
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
|
|
||||||
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
|
|
||||||
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
|
|
||||||
// бы из выборки — молча.
|
|
||||||
//
|
|
||||||
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
|
||||||
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
|
||||||
// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
|
|
||||||
// бы тем же сохранением, а число отказов остановленной записи — которое
|
|
||||||
// остановка хранит намеренно — приходило бы владельцу нулём.
|
|
||||||
func BindPanelRules(app core.App) {
|
|
||||||
bindProviderLoginIsImmutable(app)
|
|
||||||
|
|
||||||
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
|
||||||
original := e.Record.Original()
|
|
||||||
if original == nil {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChanged := original.GetString("state") != e.Record.GetString("state")
|
|
||||||
// Снятие признака остановки — то самое движение, ради которого признак и
|
|
||||||
// заведён: запись возвращается в работу с сохранённого рубежа.
|
|
||||||
resumed := !original.GetDateTime("halted_at").IsZero() &&
|
|
||||||
e.Record.GetDateTime("halted_at").IsZero()
|
|
||||||
|
|
||||||
if !stateChanged && !resumed {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запись читается уже с правкой человека: рубеж здесь тот, который он
|
|
||||||
// выбрал, а признак остановки — тот, который он снял или оставил.
|
|
||||||
record := recordToAudioRecord(e.Record)
|
|
||||||
switch {
|
|
||||||
case resumed:
|
|
||||||
record.Resume()
|
|
||||||
default:
|
|
||||||
record.MoveToState(record.State)
|
|
||||||
}
|
|
||||||
applyOwnedByPipeline(e.Record, record)
|
|
||||||
|
|
||||||
if err := e.Next(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resumed {
|
|
||||||
// Перезапуск виден в журнале событий с указанием, что его сделал
|
|
||||||
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
|
|
||||||
// запись, которая туда и не уходила.
|
|
||||||
//
|
|
||||||
// Строка пишется **после** сохранения: событие о правке, которая не
|
|
||||||
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
|
|
||||||
// правку не отменяет — журнал никем не читается ради решения.
|
|
||||||
event := &entity.RecordEvent{
|
|
||||||
RecordID: e.Record.Id,
|
|
||||||
Origin: entity.EventOriginHuman,
|
|
||||||
Step: "resume",
|
|
||||||
Outcome: entity.EventOutcomeResumed,
|
|
||||||
}
|
|
||||||
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
|
|
||||||
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
|
|
||||||
// заведения.
|
|
||||||
//
|
|
||||||
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
|
|
||||||
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
|
|
||||||
// с этим именем: владелец записи назначается один раз и не меняется, так что
|
|
||||||
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
|
|
||||||
// нет.
|
|
||||||
//
|
|
||||||
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
|
|
||||||
// панель работает суперпользователем и правила обходит по построению. Отсюда
|
|
||||||
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
|
|
||||||
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
|
|
||||||
//
|
|
||||||
// Заведение проходит: событие правки на нём не срабатывает вовсе.
|
|
||||||
//
|
|
||||||
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
|
|
||||||
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
|
|
||||||
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
|
|
||||||
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
|
|
||||||
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
|
|
||||||
// ровно тот класс, из-за которого правило и заводится.
|
|
||||||
func bindProviderLoginIsImmutable(app core.App) {
|
|
||||||
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
|
|
||||||
if err != nil {
|
|
||||||
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
was := stored.GetString(migrations.ProviderLoginField)
|
|
||||||
now := e.Record.GetString(migrations.ProviderLoginField)
|
|
||||||
|
|
||||||
if was != "" && was != now {
|
|
||||||
return errors.New("provider login is assigned once and never changes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
|
|
||||||
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
|
|
||||||
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
|
|
||||||
// того пути, которым владелец и ходит.
|
|
||||||
|
|
||||||
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
|
|
||||||
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
|
|
||||||
// правил, то есть не то, что работает в проде.
|
|
||||||
func newPanelStorage(t *testing.T) core.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
app := newTestStorage(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateByRequest правит запись так, как это делает панель: запросом, а не
|
|
||||||
// сохранением модели.
|
|
||||||
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
|
|
||||||
// хук и отличает смену рубежа от правки соседнего поля.
|
|
||||||
for key, value := range body {
|
|
||||||
record.Set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
|
|
||||||
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
|
|
||||||
// и проверка зеленела бы, не коснувшись правила.
|
|
||||||
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
|
|
||||||
event.App = app
|
|
||||||
event.Collection = collection
|
|
||||||
event.Record = record
|
|
||||||
|
|
||||||
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
|
|
||||||
return e.App.Save(e.Record)
|
|
||||||
}))
|
|
||||||
|
|
||||||
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return after
|
|
||||||
}
|
|
||||||
|
|
||||||
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
|
|
||||||
// такой её видит владелец, открывая панель.
|
|
||||||
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
record.MoveToState(entity.StateNormalized)
|
|
||||||
record.Attempts = 4
|
|
||||||
record.AcquisitionID = ptrOf("прежний-захват")
|
|
||||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
|
||||||
record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
|
|
||||||
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
|
|
||||||
// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
|
|
||||||
// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
|
|
||||||
record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)
|
|
||||||
|
|
||||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет
|
|
||||||
|
|
||||||
// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
|
|
||||||
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
|
|
||||||
// простоявшая остановленной дольше предела, остановилась бы снова первым же
|
|
||||||
// захватом — и владелец не узнал бы об этом.
|
|
||||||
func TestPanelResumeClearsEveryGuard(t *testing.T) {
|
|
||||||
app := newPanelStorage(t)
|
|
||||||
record := haltedRecord(t, app)
|
|
||||||
|
|
||||||
after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
|
|
||||||
|
|
||||||
assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
|
|
||||||
assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
|
|
||||||
assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
|
|
||||||
assert.Empty(t, after.GetString("error_text"), "и текст отказа")
|
|
||||||
assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
|
|
||||||
assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
|
|
||||||
assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
|
|
||||||
assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")
|
|
||||||
|
|
||||||
entered := after.GetDateTime("state_entered_at").Time()
|
|
||||||
assert.WithinDuration(t, clock.Now(), entered, time.Minute,
|
|
||||||
"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")
|
|
||||||
|
|
||||||
// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
|
|
||||||
acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.NoError(t, err, "запись вернулась в выборку")
|
|
||||||
assert.Equal(t, record.Id, acquired.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
|
|
||||||
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
|
|
||||||
// уходила.
|
|
||||||
func TestPanelResumeIsLogged(t *testing.T) {
|
|
||||||
app := newPanelStorage(t)
|
|
||||||
record := haltedRecord(t, app)
|
|
||||||
|
|
||||||
updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
|
|
||||||
|
|
||||||
events, err := app.FindAllRecords(migrations.RecordEventsCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var human int
|
|
||||||
for _, event := range events {
|
|
||||||
if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
|
|
||||||
human++
|
|
||||||
assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правка рубежа руками чистит служебные поля прошлого захвата так же, как
|
|
||||||
// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа,
|
|
||||||
// получит запись, которая не выдаётся захвату до конца прежнего срока.
|
|
||||||
func TestPanelStateEditClearsGuards(t *testing.T) {
|
|
||||||
app := newPanelStorage(t)
|
|
||||||
|
|
||||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
record.Attempts = 4
|
|
||||||
record.AcquisitionID = ptrOf("прежний-захват")
|
|
||||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
|
||||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
|
||||||
|
|
||||||
after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})
|
|
||||||
|
|
||||||
assert.Equal(t, entity.StateNormalized, after.GetString("state"))
|
|
||||||
assert.Empty(t, after.GetString("acquisition_id"))
|
|
||||||
assert.Equal(t, 0, after.GetInt("attempts"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
|
|
||||||
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
|
|
||||||
// заголовок, снял бы захват у работающего шага.
|
|
||||||
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
|
|
||||||
app := newPanelStorage(t)
|
|
||||||
|
|
||||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
record.Attempts = 3
|
|
||||||
record.AcquisitionID = ptrOf("живой-захват")
|
|
||||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
|
||||||
|
|
||||||
after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})
|
|
||||||
|
|
||||||
assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
|
|
||||||
assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
|
|
||||||
assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
|
|
||||||
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
|
|
||||||
// может.
|
|
||||||
func TestAcquireCarriesStageDeadline(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
repo := NewAudioRecordRepository(app)
|
|
||||||
|
|
||||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, record.Id, acquired.ID)
|
|
||||||
require.NotEmpty(t, acquired.Holder)
|
|
||||||
|
|
||||||
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))
|
|
||||||
|
|
||||||
stage, ok := entity.StageByName(entity.StateUploaded)
|
|
||||||
require.True(t, ok)
|
|
||||||
expected := clock.Now().Add(stage.AcquireTimeout)
|
|
||||||
assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
|
|
||||||
"срок протухания приехал с рубежа записи")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
|
|
||||||
// запись не теряется молча».
|
|
||||||
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
repo := NewAudioRecordRepository(app)
|
|
||||||
|
|
||||||
newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
|
|
||||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.NoError(t, err, "первому запись досталась")
|
|
||||||
require.NotEmpty(t, first.Holder)
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
_, err = repo.FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.Error(t, err, "остальным — признак «работы нет»")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Протухший захват возвращает запись в работу, и признак нового захвата
|
|
||||||
// отличается от прежнего: условие записи результата сверяет именно значение.
|
|
||||||
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
repo := NewAudioRecordRepository(app)
|
|
||||||
|
|
||||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
|
||||||
|
|
||||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
|
||||||
require.NoError(t, app.Save(stored))
|
|
||||||
|
|
||||||
second, err := repo.FindAndAcquire(entity.WorkingStages())
|
|
||||||
require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
|
|
||||||
assert.Equal(t, record.Id, second.ID)
|
|
||||||
assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RecognitionRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRecognitionRepository(app core.App) *RecognitionRepository {
|
|
||||||
return &RecognitionRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create заводит строку попытки **до** обращения к провайдеру.
|
|
||||||
//
|
|
||||||
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
|
|
||||||
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
|
|
||||||
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
|
|
||||||
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
|
|
||||||
collection, err := findCollection(repo.app, migrations.RecognitionsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
started := clock.Now()
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("record", r.RecordID)
|
|
||||||
record.Set("provider", r.Provider)
|
|
||||||
record.Set("model", r.Model)
|
|
||||||
record.Set("external_id", r.ExternalID)
|
|
||||||
record.Set("source_uri", r.SourceURI)
|
|
||||||
record.Set("started_at", dateOrEmpty(&started))
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Id = record.Id
|
|
||||||
r.StartedAt = &started
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
|
||||||
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
|
|
||||||
// наружу не платит.
|
|
||||||
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
|
||||||
}
|
|
||||||
record.Set("source_uri", sourceURI)
|
|
||||||
record.Set("external_id", externalID)
|
|
||||||
if err := repo.app.Save(record); 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 {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(raw) > 0 {
|
|
||||||
// Имя вложения задаём мы: умолчание хранилища строит его из имени
|
|
||||||
// исходного файла, а имя, данное отправителем, в хранилище не попадает.
|
|
||||||
payload, err := filesystem.NewFileFromBytes(raw, id+".payload")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to prepare provider payload of attempt %s", id)
|
|
||||||
}
|
|
||||||
record.Set("payload", payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
finished := clock.Now()
|
|
||||||
record.Set("finished_at", dateOrEmpty(&finished))
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
// Отказ хранилища несёт имя файла вложения целиком, а оно — последняя
|
|
||||||
// часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним.
|
|
||||||
return fmt.Errorf("failed to store provider payload of attempt %s", id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entity.Recognition{
|
|
||||||
Id: record.Id,
|
|
||||||
RecordID: record.GetString("record"),
|
|
||||||
Provider: record.GetString("provider"),
|
|
||||||
Model: record.GetString("model"),
|
|
||||||
ExternalID: record.GetString("external_id"),
|
|
||||||
SourceURI: record.GetString("source_uri"),
|
|
||||||
StartedAt: timeOrNil(record.GetDateTime("started_at")),
|
|
||||||
FinishedAt: timeOrNil(record.GetDateTime("finished_at")),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
|
|
||||||
// нужен: шаг опроса читает строку попытки без него.
|
|
||||||
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
names := record.GetStringSlice("payload")
|
|
||||||
if len(names) == 0 {
|
|
||||||
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
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() + "/" + names[0])
|
|
||||||
if err != nil {
|
|
||||||
// Отказ хранилища несёт имя вложения целиком, а имя — последняя часть
|
|
||||||
// ссылки на скачивание: наружу идёт идентификатор попытки, и только он.
|
|
||||||
return nil, errors.Join(
|
|
||||||
fmt.Errorf("failed to read stored payload of attempt %s", id),
|
|
||||||
fsys.Close(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, readErr := io.ReadAll(reader)
|
|
||||||
closeErr := errors.Join(reader.Close(), fsys.Close())
|
|
||||||
if readErr != nil {
|
|
||||||
return nil, errors.Join(
|
|
||||||
fmt.Errorf("failed to read stored payload of attempt %s", id),
|
|
||||||
closeErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return nil, closeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RecordEventRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRecordEventRepository(app core.App) *RecordEventRepository {
|
|
||||||
return &RecordEventRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append пишет строку журнала событий записи.
|
|
||||||
//
|
|
||||||
// Журнал пишется на смену рубежа, на остановку и на снятие остановки, а не на
|
|
||||||
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
|
|
||||||
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
|
|
||||||
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
|
|
||||||
// молча.
|
|
||||||
//
|
|
||||||
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
|
|
||||||
// наравне с журналом сервиса.
|
|
||||||
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
|
|
||||||
collection, err := findCollection(repo.app, migrations.RecordEventsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("record", event.RecordID)
|
|
||||||
record.Set("origin", event.Origin)
|
|
||||||
record.Set("step", event.Step)
|
|
||||||
record.Set("outcome", event.Outcome)
|
|
||||||
record.Set("outcome_text", event.OutcomeText)
|
|
||||||
record.Set("duration_ms", event.DurationMs)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
event.Id = record.Id
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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, r *entity.AudioRecord) {
|
|
||||||
record.Set("state", r.State)
|
|
||||||
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
|
|
||||||
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
|
|
||||||
record.Set("halt_reason", derefString(r.HaltReason))
|
|
||||||
record.Set("error_text", derefString(r.ErrorText))
|
|
||||||
record.Set("acquisition_id", derefString(r.AcquisitionID))
|
|
||||||
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
|
|
||||||
record.Set("delay_time", dateOrEmpty(r.DelayTime))
|
|
||||||
record.Set("attempts", r.Attempts)
|
|
||||||
record.Set("original_file", derefString(r.OriginalFileID))
|
|
||||||
record.Set("normalized_file", derefString(r.NormalizedFileID))
|
|
||||||
record.Set("transcript_text", derefString(r.TranscriptTextID))
|
|
||||||
record.Set("literary_text", derefString(r.LiteraryTextID))
|
|
||||||
record.Set("structure", derefString(r.StructureID))
|
|
||||||
record.Set("recognition", derefString(r.RecognitionID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
|
|
||||||
// не с кем.
|
|
||||||
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
|
||||||
applyOwnedByPipeline(record, r)
|
|
||||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
|
||||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
|
||||||
// записанный поверх, стёр бы его молча.
|
|
||||||
record.Set("owner", r.OwnerID)
|
|
||||||
record.Set("source", r.Source)
|
|
||||||
record.Set("title", derefString(r.Title))
|
|
||||||
record.Set("brief", derefString(r.Brief))
|
|
||||||
// Имя файла отправителя, длительность и размер кладёт приём и только он: это
|
|
||||||
// снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их
|
|
||||||
// нет намеренно — снимок шага, записанный поверх, стёр бы их молча.
|
|
||||||
record.Set("original_filename", derefString(r.OriginalFilename))
|
|
||||||
record.Set("duration_ms", numberOrZero(r.DurationMs))
|
|
||||||
record.Set("size_bytes", numberOrZero(r.SizeBytes))
|
|
||||||
// Темы кладутся при заведении пустыми и конвейером не трогаются: считает их
|
|
||||||
// языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением —
|
|
||||||
// колонка, которую читают и не пишут, ничем не отличима от забытой.
|
|
||||||
record.Set("topics", r.TopicIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
|
||||||
return &entity.AudioRecord{
|
|
||||||
Id: record.Id,
|
|
||||||
OwnerID: record.GetString("owner"),
|
|
||||||
Source: record.GetString("source"),
|
|
||||||
Title: nilIfEmpty(record.GetString("title")),
|
|
||||||
Brief: nilIfEmpty(record.GetString("brief")),
|
|
||||||
State: record.GetString("state"),
|
|
||||||
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
|
|
||||||
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
|
|
||||||
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
|
|
||||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
|
||||||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
|
||||||
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
|
|
||||||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
|
||||||
Attempts: record.GetInt("attempts"),
|
|
||||||
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
|
|
||||||
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
|
|
||||||
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
|
|
||||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
|
||||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
|
||||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
|
||||||
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
|
|
||||||
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
|
|
||||||
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
|
|
||||||
// инвариант о колонках компилятор не проверяет.
|
|
||||||
DurationMs: numberValue(record.GetInt("duration_ms")),
|
|
||||||
SizeBytes: numberValue(record.GetInt("size_bytes")),
|
|
||||||
TopicIDs: record.GetStringSlice("topics"),
|
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
|
||||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefString(v *string) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
|
||||||
// хранилище это пустая строка, и она же значит «времени нет».
|
|
||||||
func dateOrEmpty(v *time.Time) any {
|
|
||||||
if v == nil || v.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
date, err := types.ParseDateTime(*v)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
// numberOrZero отдаёт ноль вместо отсутствующего числа.
|
|
||||||
//
|
|
||||||
// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она
|
|
||||||
// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака
|
|
||||||
// либо текстового типа у чисел, и платить за это нечем — обе величины ставит
|
|
||||||
// приём и ставит всегда. Решение владельца 2026-08-15.
|
|
||||||
func numberOrZero(v *int64) int64 {
|
|
||||||
if v == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero.
|
|
||||||
func numberValue(value int) *int64 {
|
|
||||||
v := int64(value)
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"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 AudioRecordRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAudioRecordRepository(app core.App) *AudioRecordRepository {
|
|
||||||
return &AudioRecordRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
|
||||||
collection, err := findCollection(repo.app, migrations.RecordsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
if r.Id != "" {
|
|
||||||
record.Id = r.Id
|
|
||||||
}
|
|
||||||
applyToRecord(record, r)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to insert audio record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Id = record.Id
|
|
||||||
r.CreatedAt = record.GetDateTime("created").Time()
|
|
||||||
r.UpdatedAt = record.GetDateTime("updated").Time()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save сохраняет запись, захват которой держит holder. Проверка и запись идут
|
|
||||||
// одной транзакцией: шаг, потерявший запись за время работы, получает
|
|
||||||
// LostAcquisitionError и результата не пишет.
|
|
||||||
//
|
|
||||||
// Сверяется **значение** признака захвата, а не занятость записи. Захват,
|
|
||||||
// перевыданный другому — по протуханию срока или после того, как человек снял
|
|
||||||
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
|
|
||||||
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
|
|
||||||
// запись по очереди, портя её результат.
|
|
||||||
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
|
||||||
return repo.app.RunInTransaction(func(txApp core.App) error {
|
|
||||||
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find audio record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if holder != "" && record.GetString("acquisition_id") != holder {
|
|
||||||
return &contract.LostAcquisitionError{JobID: r.Id}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кладём только то, чем распоряжается конвейер: правку владельца в
|
|
||||||
// панели снимок шага стирать не должен.
|
|
||||||
applyOwnedByPipeline(record, r)
|
|
||||||
|
|
||||||
if err := txApp.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to update audio record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.UpdatedAt = record.GetDateTime("updated").Time()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.find(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if record.GetString("owner") != ownerID {
|
|
||||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return recordToAudioRecord(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
|
|
||||||
// владельцем не сужается.
|
|
||||||
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
|
|
||||||
record, err := repo.find(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return recordToAudioRecord(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *AudioRecordRepository) find(id string) (*core.Record, error) {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.RecordsCollection, id)
|
|
||||||
if 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)
|
|
||||||
}
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
|
|
||||||
// выбор подходящей и пометка её захваченной идут вместе.
|
|
||||||
//
|
|
||||||
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
|
|
||||||
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
|
|
||||||
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
|
|
||||||
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
|
|
||||||
//
|
|
||||||
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
|
|
||||||
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
|
|
||||||
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
|
|
||||||
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
|
||||||
// проекта не пишется в журнал и не считается в метрику.
|
|
||||||
//
|
|
||||||
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
|
|
||||||
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
|
|
||||||
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
|
|
||||||
// запрос.
|
|
||||||
//
|
|
||||||
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
|
|
||||||
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
|
|
||||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
|
||||||
// ложь — молча.
|
|
||||||
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
|
|
||||||
if len(stages) == 0 {
|
|
||||||
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
|
|
||||||
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
|
|
||||||
// этом запросе обошла бы единую точку молча.
|
|
||||||
now, err := types.ParseDateTime(clock.Now())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse current time: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder := uuid.NewString()
|
|
||||||
params := dbx.Params{
|
|
||||||
"holder": holder,
|
|
||||||
"now": now.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
|
|
||||||
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
|
|
||||||
// подставить его не может.
|
|
||||||
var expiry strings.Builder
|
|
||||||
expiry.WriteString("CASE state")
|
|
||||||
var states []string
|
|
||||||
for i, stage := range stages {
|
|
||||||
stateKey := fmt.Sprintf("state%d", i)
|
|
||||||
expiryKey := fmt.Sprintf("expiry%d", i)
|
|
||||||
|
|
||||||
deadline, err := types.ParseDateTime(clock.Now().Add(stage.AcquireTimeout))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse acquire deadline: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(&expiry, " WHEN {:%s} THEN {:%s}", stateKey, expiryKey)
|
|
||||||
params[stateKey] = stage.Name
|
|
||||||
params[expiryKey] = deadline.String()
|
|
||||||
states = append(states, "{:"+stateKey+"}")
|
|
||||||
}
|
|
||||||
expiry.WriteString(" END")
|
|
||||||
|
|
||||||
table := "{{" + migrations.RecordsCollection + "}}"
|
|
||||||
query := repo.app.DB().NewQuery(`
|
|
||||||
UPDATE ` + table + `
|
|
||||||
SET acquisition_id = {:holder},
|
|
||||||
acquire_expires_at = ` + expiry.String() + `,
|
|
||||||
attempts = attempts + 1,
|
|
||||||
updated = {:now}
|
|
||||||
WHERE id = (
|
|
||||||
SELECT id FROM ` + table + `
|
|
||||||
WHERE state IN (` + strings.Join(states, ", ") + `)
|
|
||||||
AND (halted_at = '' OR halted_at IS NULL)
|
|
||||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
|
||||||
AND (acquisition_id = '' OR acquisition_id IS NULL
|
|
||||||
OR acquire_expires_at = '' OR acquire_expires_at IS NULL
|
|
||||||
OR acquire_expires_at < {:now})
|
|
||||||
ORDER BY created, id
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
RETURNING id`)
|
|
||||||
|
|
||||||
query.Bind(params)
|
|
||||||
|
|
||||||
var row struct {
|
|
||||||
Id string `db:"id"`
|
|
||||||
}
|
|
||||||
if err := query.One(&row); 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: row.Id, Holder: holder}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
|
||||||
// тем же путём, каким это делает сервис при старте.
|
|
||||||
func newTestStorage(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно
|
|
||||||
// переехало.
|
|
||||||
//
|
|
||||||
// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про
|
|
||||||
// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая
|
|
||||||
// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка
|
|
||||||
// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому,
|
|
||||||
// кто её знает, без сессии.
|
|
||||||
func TestRecordContentIsClosedEverywhere(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
// Правило просмотра остаётся незаданным, то есть «только владелец панели».
|
|
||||||
// Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища;
|
|
||||||
// непустое правило открыло бы перечисление коллекции впрок.
|
|
||||||
for _, name := range []string{
|
|
||||||
migrations.RecordsCollection,
|
|
||||||
migrations.TextsCollection,
|
|
||||||
migrations.StructuresCollection,
|
|
||||||
migrations.RecognitionsCollection,
|
|
||||||
migrations.RecordEventsCollection,
|
|
||||||
migrations.TopicsCollection,
|
|
||||||
} {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
require.NoError(t, err, "коллекция %s заведена шагом схемы", name)
|
|
||||||
|
|
||||||
assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name)
|
|
||||||
assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name)
|
|
||||||
assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name)
|
|
||||||
assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name)
|
|
||||||
assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// А поле вложения помечено защищённым: без пометки ссылка открывает
|
|
||||||
// содержимое любому, кто её знает, и знание ссылки становится правом.
|
|
||||||
recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
field := recognitions.Fields.GetByName("payload")
|
|
||||||
require.NotNil(t, field, "поле сохранённого ответа заведено")
|
|
||||||
|
|
||||||
file, ok := field.(*core.FileField)
|
|
||||||
require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой")
|
|
||||||
assert.True(t, file.Protected, "поле вложения защищено")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
|
|
||||||
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
|
|
||||||
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
|
|
||||||
//
|
|
||||||
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
|
|
||||||
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
|
|
||||||
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
|
|
||||||
// узнавание жило под корнем приложения, до этой поверхности браузер не
|
|
||||||
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
|
|
||||||
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
|
|
||||||
// имени.
|
|
||||||
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
|
|
||||||
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
|
|
||||||
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
|
|
||||||
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
|
|
||||||
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
|
|
||||||
|
|
||||||
// Собственные способы войти выключены там же: без этого узнавание по
|
|
||||||
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
|
|
||||||
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
|
|
||||||
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
|
|
||||||
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
|
|
||||||
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormerJobsCollectionIsGone(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
_, err := app.FindCollectionByNameOrId(migrations.JobsCollection)
|
|
||||||
assert.Error(t, err, "прежней коллекции задач не осталось")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго
|
|
||||||
// комплекта строк, и вопрос «какой текст отдавать человеку» не становится
|
|
||||||
// вопросом порядка записи.
|
|
||||||
func TestAppendicesAreUniquePerRecord(t *testing.T) {
|
|
||||||
app := newTestStorage(t)
|
|
||||||
|
|
||||||
indexes := map[string][]string{
|
|
||||||
migrations.TextsCollection: {"idx_texts_record_kind"},
|
|
||||||
migrations.StructuresCollection: {"idx_structures_record_version"},
|
|
||||||
migrations.TopicsCollection: {"idx_topics_owner_name"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, expected := range indexes {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, index := range expected {
|
|
||||||
var found bool
|
|
||||||
for _, declared := range collection.Indexes {
|
|
||||||
if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TextRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTextRepository(app core.App) *TextRepository {
|
|
||||||
return &TextRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put кладёт текст записи, заменяя прежний того же вида.
|
|
||||||
//
|
|
||||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
|
||||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
|
||||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
|
||||||
//
|
|
||||||
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
|
||||||
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
|
||||||
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
|
|
||||||
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
|
||||||
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
|
||||||
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
|
|
||||||
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
|
|
||||||
// результата читалось бы как недосмотр.
|
|
||||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
|
||||||
collection, err := findCollection(repo.app, migrations.TextsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := repo.app.FindFirstRecordByFilter(
|
|
||||||
migrations.TextsCollection,
|
|
||||||
"record = {:record} && kind = {:kind}",
|
|
||||||
dbx.Params{"record": recordID, "kind": kind},
|
|
||||||
)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
// Строка есть — заменяем содержимое.
|
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
|
||||||
record = core.NewRecord(collection)
|
|
||||||
record.Set("record", recordID)
|
|
||||||
record.Set("kind", kind)
|
|
||||||
default:
|
|
||||||
// Отказ хранилища «строкой нет» не является, и подменять его вставкой
|
|
||||||
// нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на
|
|
||||||
// запись вместо правды о недоступной базе.
|
|
||||||
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
|
||||||
}
|
|
||||||
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
|
||||||
// есть, и вызывающий получает её обратно.
|
|
||||||
if contents == "" && record.GetString("contents") != "" {
|
|
||||||
return textFromRecord(record), nil
|
|
||||||
}
|
|
||||||
record.Set("contents", contents)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
|
|
||||||
// хранилища несёт значение поля.
|
|
||||||
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return textFromRecord(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.TextsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return textFromRecord(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func textFromRecord(record *core.Record) *entity.Text {
|
|
||||||
return &entity.Text{
|
|
||||||
Id: record.Id,
|
|
||||||
RecordID: record.GetString("record"),
|
|
||||||
Kind: record.GetString("kind"),
|
|
||||||
Contents: record.GetString("contents"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type StructureRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStructureRepository(app core.App) *StructureRepository {
|
|
||||||
return &StructureRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
|
|
||||||
// же, что и у текста: повтор шага не должен заводить второй строки.
|
|
||||||
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
|
|
||||||
collection, err := findCollection(repo.app, migrations.StructuresCollection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
contents, err := json.Marshal(replicas)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := repo.app.FindFirstRecordByFilter(
|
|
||||||
migrations.StructuresCollection,
|
|
||||||
"record = {:record} && version = {:version}",
|
|
||||||
dbx.Params{"record": recordID, "version": version},
|
|
||||||
)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
|
|
||||||
// непустого не кладётся по тому же доводу, что и у текста: повторный
|
|
||||||
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
|
|
||||||
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
|
|
||||||
return repo.GetByID(record.Id)
|
|
||||||
}
|
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
|
||||||
record = core.NewRecord(collection)
|
|
||||||
record.Set("record", recordID)
|
|
||||||
record.Set("version", version)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
|
|
||||||
}
|
|
||||||
record.Set("contents", string(contents))
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entity.Structure{
|
|
||||||
Id: record.Id,
|
|
||||||
RecordID: recordID,
|
|
||||||
Version: version,
|
|
||||||
Replicas: replicas,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
|
|
||||||
record, err := repo.app.FindRecordById(migrations.StructuresCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var replicas []entity.Replica
|
|
||||||
raw := record.GetString("contents")
|
|
||||||
if raw != "" {
|
|
||||||
if err := json.Unmarshal([]byte(raw), &replicas); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode structure %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entity.Structure{
|
|
||||||
Id: record.Id,
|
|
||||||
RecordID: record.GetString("record"),
|
|
||||||
Version: record.GetInt("version"),
|
|
||||||
Replicas: replicas,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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, ¬Used, &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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+136
-77
@@ -1,12 +1,10 @@
|
|||||||
package pocketbase
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -47,69 +45,86 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
|
|||||||
q.Limit = defaultListLimit
|
q.Limit = defaultListLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
collection, err := findCollection(repo.app, migrations.RecordsCollection)
|
conditions := []string{"owner_id = ?"}
|
||||||
|
args := []any{q.OwnerID}
|
||||||
|
|
||||||
|
state, stateArgs, err := stateCondition(q.Filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if state != "" {
|
||||||
filter := dbx.HashExp{"owner": q.OwnerID}
|
|
||||||
conditions := []dbx.Expression{filter}
|
|
||||||
|
|
||||||
state, err := stateCondition(q.Filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if state != nil {
|
|
||||||
conditions = append(conditions, state)
|
conditions = append(conditions, state)
|
||||||
|
args = append(args, stateArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := repo.countRecords(conditions)
|
total, err := repo.countRecords(conditions, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageConditions := conditions
|
||||||
|
pageArgs := args
|
||||||
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
||||||
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
|
// — строго меньше по идентификатору.
|
||||||
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
|
|
||||||
// этого довольно: задача ключа — не пропустить и не повторить.
|
|
||||||
if q.Cursor != nil {
|
if q.Cursor != nil {
|
||||||
conditions = append(conditions, dbx.Or(
|
pageConditions = append(append([]string{}, conditions...),
|
||||||
dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
|
"(created_at < ? OR (created_at = ? AND id < ?))")
|
||||||
dbx.And(
|
cursorTime := formatTime(q.Cursor.CreatedAt)
|
||||||
dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
|
pageArgs = append(append([]any{}, args...),
|
||||||
dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}),
|
cursorTime, cursorTime, q.Cursor.ID)
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row := &recordRow{}
|
||||||
|
columns, targets := selectList(readRecordColumns(row), "")
|
||||||
|
|
||||||
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
||||||
// следующая страница» без второго запроса и без вычислений по общему числу,
|
// следующая страница» без второго запроса и без вычислений по общему числу,
|
||||||
// которое к этому моменту могло измениться.
|
// которое к этому моменту могло измениться.
|
||||||
records := []*core.Record{}
|
query := "SELECT " + columns + " FROM " + recordsTable +
|
||||||
err = repo.app.RecordQuery(collection).
|
" WHERE " + strings.Join(pageConditions, " AND ") +
|
||||||
AndWhere(dbx.And(conditions...)).
|
" ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||||
OrderBy("created DESC", "id DESC").
|
pageArgs = append(pageArgs, q.Limit+1)
|
||||||
Limit(int64(q.Limit) + 1).
|
|
||||||
All(&records)
|
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list audio records: %w", err)
|
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}
|
page := &contract.RecordPage{TotalItems: total}
|
||||||
if len(records) > q.Limit {
|
if len(items) > q.Limit {
|
||||||
last := records[q.Limit-1]
|
last := items[q.Limit-1]
|
||||||
page.NextCursor = &contract.RecordCursor{
|
page.NextCursor = &contract.RecordCursor{
|
||||||
CreatedAt: last.GetDateTime("created").String(),
|
CreatedAt: last.CreatedAt,
|
||||||
ID: last.Id,
|
ID: last.Id,
|
||||||
}
|
}
|
||||||
records = records[:q.Limit]
|
items = items[:q.Limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
page.Items = make([]*entity.AudioRecord, 0, len(records))
|
ids := make([]string, 0, len(items))
|
||||||
for _, record := range records {
|
for _, item := range items {
|
||||||
page.Items = append(page.Items, recordToAudioRecord(record))
|
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
|
return page, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,84 +133,128 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
|
|||||||
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
||||||
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
||||||
// молча поменял бы состав всех трёх состояний.
|
// молча поменял бы состав всех трёх состояний.
|
||||||
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
|
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
|
||||||
if filter == nil {
|
if filter == nil {
|
||||||
return nil, nil
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
notHalted := dbx.NewExp("halted_at = ''")
|
|
||||||
halted := dbx.NewExp("halted_at != ''")
|
|
||||||
|
|
||||||
switch *filter {
|
switch *filter {
|
||||||
case entity.ListFilterHalted:
|
case entity.ListFilterHalted:
|
||||||
return halted, nil
|
return "halted_at IS NOT NULL", nil, nil
|
||||||
case entity.ListFilterWorking:
|
case entity.ListFilterWorking:
|
||||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
|
condition, args := stateIn(entity.WorkingStages())
|
||||||
|
return "halted_at IS NULL AND " + condition, args, nil
|
||||||
case entity.ListFilterDone:
|
case entity.ListFilterDone:
|
||||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
|
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)
|
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stageNameValues(stages []entity.Stage) []any {
|
func stateIn(stages []entity.Stage) (string, []any) {
|
||||||
names := entity.StageNames(stages)
|
names := entity.StageNames(stages)
|
||||||
out := make([]any, 0, len(names))
|
args := make([]any, 0, len(names))
|
||||||
for _, n := range names {
|
placeholders := make([]string, 0, len(names))
|
||||||
out = append(out, n)
|
for _, name := range names {
|
||||||
|
args = append(args, name)
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
}
|
}
|
||||||
return out
|
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) {
|
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
|
||||||
var counter struct {
|
var total int
|
||||||
Total int `db:"total"`
|
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
|
||||||
}
|
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
|
||||||
err := repo.app.RecordQuery(migrations.RecordsCollection).
|
|
||||||
Select("count(*) as total").
|
|
||||||
AndWhere(dbx.And(conditions...)).
|
|
||||||
One(&counter)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to count audio records: %w", err)
|
return 0, fmt.Errorf("failed to count audio records: %w", err)
|
||||||
}
|
}
|
||||||
return counter.Total, nil
|
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 разрешает темы названиями **одним запросом на страницу**, а
|
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
|
||||||
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни
|
// не по запросу на запись.
|
||||||
// обращений к хранилищу.
|
|
||||||
//
|
//
|
||||||
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
|
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
|
||||||
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
|
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
|
||||||
// чего контракт согласуется один раз.
|
// чего контракт согласуется один раз.
|
||||||
|
//
|
||||||
|
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
|
||||||
|
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
|
||||||
|
// чужой темы, как только темы начнёт писать языковая модель.
|
||||||
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
|
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
if len(ids) == 0 || ownerID == "" {
|
if len(ids) == 0 || ownerID == "" {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
values := make([]any, 0, len(ids))
|
placeholders := make([]string, 0, len(ids))
|
||||||
|
args := []any{ownerID}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
values = append(values, id)
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
|
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
|
||||||
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
|
strings.Join(placeholders, ", ") + ")"
|
||||||
// название чужой темы, как только темы начнёт писать языковая модель.
|
|
||||||
records := []*core.Record{}
|
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
|
||||||
err := repo.app.RecordQuery(migrations.TopicsCollection).
|
|
||||||
AndWhere(dbx.HashExp{"owner": ownerID}).
|
|
||||||
AndWhere(dbx.In("id", values...)).
|
|
||||||
All(&records)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for _, record := range records {
|
for rows.Next() {
|
||||||
out[record.Id] = record.GetString("name")
|
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
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+152
-40
@@ -113,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) {
|
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
|
||||||
for pkg, imports := range internalImports(t) {
|
for pkg, imports := range internalImports(t) {
|
||||||
if !strings.HasPrefix(pkg, adapterPrefix) {
|
if !strings.HasPrefix(pkg, adapterPrefix) {
|
||||||
@@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
|
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
|
||||||
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
|
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
|
||||||
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
|
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
|
||||||
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
|
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
|
||||||
|
// полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||||
//
|
//
|
||||||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
// Отображение работает **по имени колонки** — именованные параметры запроса и
|
||||||
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
|
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
|
||||||
// ниже держат оставшуюся пару плюс шаг схемы.
|
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
|
||||||
|
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
|
||||||
|
// отображения нет вовсе.
|
||||||
const (
|
const (
|
||||||
repoPkg = "internal/adapter/repo/pocketbase"
|
repoPkg = "internal/adapter/repo/sqlite"
|
||||||
mappingFile = repoPkg + "/record_mapping.go"
|
mappingFile = repoPkg + "/record_mapping.go"
|
||||||
migrationsPath = repoPkg + "/migrations"
|
migrationsPath = repoPkg + "/migrations"
|
||||||
stageFile = "internal/entity/stage.go"
|
stageFile = "internal/entity/stage.go"
|
||||||
@@ -190,46 +218,64 @@ const (
|
|||||||
serviceFile = "internal/service/transcribe.go"
|
serviceFile = "internal/service/transcribe.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
|
|
||||||
// касаются.
|
|
||||||
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
|
||||||
|
|
||||||
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
||||||
written := writtenColumns(t)
|
written := writtenColumns(t)
|
||||||
read := readColumns(t)
|
read := readColumns(t)
|
||||||
|
|
||||||
for col := range written {
|
for col := range written {
|
||||||
if storageOwned[col] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !read[col] {
|
if !read[col] {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
|
"колонку %q пишет отображение записи, но readRecordColumns её не "+
|
||||||
"читает: запись приедет из хранилища без этого поля",
|
"читает: запись приедет из базы без этого поля",
|
||||||
col,
|
col,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for col := range read {
|
for col := range read {
|
||||||
if storageOwned[col] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !written[col] {
|
if !written[col] {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
|
"колонку %q читает readRecordColumns, но её не пишет ни "+
|
||||||
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
|
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
|
||||||
col,
|
col,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
|
||||||
|
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
|
||||||
|
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
|
||||||
|
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
|
||||||
|
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
|
||||||
|
// пишет этот ноль поверх сохранённого.
|
||||||
|
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
|
||||||
|
targets := readTargets(t)
|
||||||
|
used := rowFieldsTakenByEntity(t)
|
||||||
|
|
||||||
|
for field, column := range targets {
|
||||||
|
if !used[field] {
|
||||||
|
t.Errorf(
|
||||||
|
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
|
||||||
|
"значение не доедет до сущности, а ближайшее сохранение запишет "+
|
||||||
|
"нулевое поверх сохранённого",
|
||||||
|
column, field,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field := range used {
|
||||||
|
if _, ok := targets[field]; !ok {
|
||||||
|
t.Errorf(
|
||||||
|
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
|
||||||
|
"колонки в него не кладёт: сущность получит нулевое значение всегда",
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
||||||
declared := schemaFieldNames(t)
|
declared := schemaFieldNames(t)
|
||||||
for col := range writtenColumns(t) {
|
for col := range writtenColumns(t) {
|
||||||
if storageOwned[col] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !declared[col] {
|
if !declared[col] {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
||||||
@@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
|
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
|
||||||
|
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
|
||||||
|
|
||||||
|
// writtenColumns — колонки, которые пишет отображение записи в базу.
|
||||||
func writtenColumns(t *testing.T) map[string]bool {
|
func writtenColumns(t *testing.T) map[string]bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
|
||||||
funcBody(t, mappingFile, "func applyToRecord(")
|
funcBody(t, mappingFile, "func writeRecord(")
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
|
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||||
out[m[1]] = true
|
out[m[1]] = true
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
@@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// readColumns — колонки, которые читает обратное отображение.
|
// readColumns — колонки, которые читает обратное отображение. Перечень выборки
|
||||||
|
// собирается из той же карты, поэтому сверяется именно она.
|
||||||
func readColumns(t *testing.T) map[string]bool {
|
func readColumns(t *testing.T) map[string]bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
|
body := funcBody(t, mappingFile, "func readRecordColumns(")
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
|
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||||
out[m[1]] = true
|
out[m[1]] = true
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
|
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := 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
|
return out
|
||||||
}
|
}
|
||||||
@@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
|
|||||||
return body[start : start+end]
|
return body[start : start+end]
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
|
// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
|
||||||
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
|
//
|
||||||
// который её добавил, а переписывать применённый шаг нельзя.
|
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
|
||||||
|
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
|
||||||
|
// поэтому объединённый — по всем шагам сразу.
|
||||||
func schemaFieldNames(t *testing.T) map[string]bool {
|
func schemaFieldNames(t *testing.T) map[string]bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := filepath.Join(repoRoot, migrationsPath)
|
dir := filepath.Join(repoRoot, migrationsPath)
|
||||||
@@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("читаю каталог шагов схемы: %v", err)
|
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{}
|
out := map[string]bool{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||||
@@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("читаю %s: %v", e.Name(), err)
|
t.Fatalf("читаю %s: %v", e.Name(), err)
|
||||||
}
|
}
|
||||||
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
|
for _, block := range tableBlocks(string(body), recordsTable) {
|
||||||
out[m[1]] = true
|
for _, m := range column.FindAllStringSubmatch(block, -1) {
|
||||||
|
out[m[1]] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
|
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
|
||||||
}
|
}
|
||||||
return out
|
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 {
|
func readFile(t *testing.T, rel string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
|
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
|
||||||
|
|||||||
@@ -64,10 +64,38 @@ type ServerConfig struct {
|
|||||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
|
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
|
||||||
// записей. Двух путей, как было раньше, у хранилища не бывает.
|
//
|
||||||
|
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
|
||||||
|
// было раньше, у хранилища не бывает.
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
DataDir string `toml:"data_dir"`
|
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 {
|
type YandexConfig struct {
|
||||||
@@ -156,6 +184,13 @@ func defaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
DataDir: "data",
|
DataDir: "data",
|
||||||
|
// Пять секунд ожидания и четыре читающих соединения: числа выведены
|
||||||
|
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
|
||||||
|
// пережить чужую запись, а не чужую работу: пишет сервис короткими
|
||||||
|
// операциями, и очередь из трёх воркеров укладывается в него с
|
||||||
|
// запасом.
|
||||||
|
BusyTimeoutMs: 5000,
|
||||||
|
ReadConnections: 4,
|
||||||
},
|
},
|
||||||
Pipeline: PipelineConfig{
|
Pipeline: PipelineConfig{
|
||||||
Workers: 3,
|
Workers: 3,
|
||||||
|
|||||||
@@ -216,3 +216,42 @@ func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает
|
||||||
|
// свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание
|
||||||
|
// отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без
|
||||||
|
// предела, а пустой каталог данных оставляет сервис без места под базу и файлы.
|
||||||
|
// Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на
|
||||||
|
// подъёме.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import (
|
|||||||
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||||
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||||
// него не кладётся никогда.
|
// него не кладётся никогда.
|
||||||
|
//
|
||||||
|
// Достижимого случая у него нет: узнавание заводит учётную запись само, и
|
||||||
|
// предъявителя без неё под корнем приложения не бывает. Отдельной ветви ответа
|
||||||
|
// он поэтому не получает — ветвь по умолчанию читает его как аварию сервиса,
|
||||||
|
// каковой он и был бы.
|
||||||
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
||||||
|
|
||||||
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник
|
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник
|
||||||
@@ -31,6 +36,18 @@ var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit")
|
|||||||
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
||||||
var ErrTextNotReady = errors.New("requested text view is not ready yet")
|
var ErrTextNotReady = errors.New("requested text view is not ready yet")
|
||||||
|
|
||||||
|
// ErrCopyNotReady — копии записи запрошенного вида у неё ещё нет. Состояние, а
|
||||||
|
// не отсутствие, и код у него тот же, что у ненаписанного текста: пустой ответ
|
||||||
|
// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
|
||||||
|
// неизвестную запись — человек увидел бы его на своей записи, загруженной
|
||||||
|
// минуту назад.
|
||||||
|
var ErrCopyNotReady = errors.New("requested file copy is not ready yet")
|
||||||
|
|
||||||
|
// ErrTooManyRequests — бюджет ограничителя частоты выбран. Признак заводится
|
||||||
|
// затем, чтобы отказ ограничителя уходил той же формой тела, что и отказ
|
||||||
|
// обработчика: он рождается слоем и до обработчика не доходит вовсе.
|
||||||
|
var ErrTooManyRequests = errors.New("request rate budget is exhausted")
|
||||||
|
|
||||||
// ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста,
|
// ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста,
|
||||||
// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable
|
// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable
|
||||||
// тем, что негодна **просьба**, а не присланная запись.
|
// тем, что негодна **просьба**, а не присланная запись.
|
||||||
@@ -47,6 +64,11 @@ var ErrUnauthorized = errors.New("session is required")
|
|||||||
// отсутствующий адрес отсутствующей записью.
|
// отсутствующий адрес отсутствующей записью.
|
||||||
var ErrNotFound = errors.New("address not found")
|
var ErrNotFound = errors.New("address not found")
|
||||||
|
|
||||||
|
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
||||||
|
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а негодный
|
||||||
|
// ввод, и звать по нему учётную запись не надо.
|
||||||
|
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
||||||
|
|
||||||
type JobNotFoundError struct {
|
type JobNotFoundError struct {
|
||||||
State string
|
State string
|
||||||
Message string
|
Message string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package contract
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -40,19 +41,24 @@ type FileRepository interface {
|
|||||||
StageEmpty(ext string) (WorkFile, error)
|
StageEmpty(ext string) (WorkFile, error)
|
||||||
// Localize выдаёт рабочую копию хранимого файла.
|
// Localize выдаёт рабочую копию хранимого файла.
|
||||||
Localize(fileID string) (WorkFile, error)
|
Localize(fileID string) (WorkFile, error)
|
||||||
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
|
// Create кладёт рабочую копию в каталог данных под именем name и заводит
|
||||||
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
|
// строку о файле. Имя задаёт сервис, и имя, данное отправителем, в него не
|
||||||
// отправителя, не применяется.
|
// попадает: от него взято только расширение.
|
||||||
|
//
|
||||||
|
// recordID — запись, которой копия принадлежит: копии одной записи лежат её
|
||||||
|
// подкаталогом, и имя этого подкаталога и есть идентификатор записи. Приём
|
||||||
|
// знает его раньше, чем кладёт файл, потому что назначает сам.
|
||||||
//
|
//
|
||||||
// ownerID — владелец записи, которой файл принадлежит, и он обязателен:
|
// ownerID — владелец записи, которой файл принадлежит, и он обязателен:
|
||||||
// колонка владельца пустого значения не принимает, пустой отвергается
|
// колонка владельца пустого значения не принимает, пустой отвергается
|
||||||
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
|
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
|
||||||
// переживает свою запись — шаг заводит его до сохранения, и потерянный
|
// переживает свою запись — шаг заводит его до сохранения, и потерянный
|
||||||
// захват оставляет файл с владельцем и без ссылки.
|
// захват оставляет файл с владельцем и без ссылки.
|
||||||
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
Create(recordID, name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
||||||
GetByID(id string) (*entity.File, error)
|
GetByID(id string) (*entity.File, error)
|
||||||
// Open отдаёт содержимое хранимого файла потоком.
|
// Open отдаёт содержимое хранимого файла потоком с перемоткой: отдача по
|
||||||
Open(fileID string) (io.ReadCloser, error)
|
// диапазону читает запрошенный кусок, а не файл целиком.
|
||||||
|
Open(fileID string) (io.ReadSeekCloser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
|
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
|
||||||
@@ -74,8 +80,12 @@ type AcquiredRecord struct {
|
|||||||
// RecordCursor — положение в ленте записей, заданное **полным** ключом
|
// RecordCursor — положение в ленте записей, заданное **полным** ключом
|
||||||
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
|
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
|
||||||
// совпадает, и порядок между ними иначе не определён.
|
// совпадает, и порядок между ними иначе не определён.
|
||||||
|
//
|
||||||
|
// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в
|
||||||
|
// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный
|
||||||
|
// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь.
|
||||||
type RecordCursor struct {
|
type RecordCursor struct {
|
||||||
CreatedAt string
|
CreatedAt time.Time
|
||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +164,8 @@ type RecognitionRepository interface {
|
|||||||
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
||||||
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
|
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
|
||||||
Submitted(id, sourceURI, externalID string) error
|
Submitted(id, sourceURI, externalID string) error
|
||||||
// Finish отмечает завершение операции и кладёт сырой ответ вложением.
|
// Finish отмечает завершение операции и кладёт сохранённый ответ отдельным
|
||||||
|
// файлом в подкаталоге записи.
|
||||||
Finish(id string, raw []byte) error
|
Finish(id string, raw []byte) error
|
||||||
GetByID(id string) (*entity.Recognition, error)
|
GetByID(id string) (*entity.Recognition, error)
|
||||||
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
|
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
|
||||||
@@ -166,3 +177,31 @@ type RecognitionRepository interface {
|
|||||||
type RecordEventRepository interface {
|
type RecordEventRepository interface {
|
||||||
Append(event *entity.RecordEvent) error
|
Append(event *entity.RecordEvent) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity — то, чем доверенный источник называет пришедшего.
|
||||||
|
//
|
||||||
|
// Логин — ключ учётной записи, остальное берётся только при её заведении.
|
||||||
|
type Identity struct {
|
||||||
|
Login string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAccount — учётная запись сервиса, какой её видит транспорт: ключ и имя,
|
||||||
|
// пригодное к показу. Логина у провайдера и адреса почты здесь нет: оба
|
||||||
|
// принадлежат человеку, а не сервису, и наружу не выходят.
|
||||||
|
type UserAccount struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepository — учётные записи.
|
||||||
|
//
|
||||||
|
// Дом правила «найти по логину, а не найдя — завести» один, и он в хранилище, а
|
||||||
|
// не в транспорте: второй способ представиться возьмёт этот же метод.
|
||||||
|
type UserRepository interface {
|
||||||
|
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
|
||||||
|
// заводит её. Второе значение истинно только у заведённой: заведение —
|
||||||
|
// событие, и владелец обязан видеть его строкой журнала.
|
||||||
|
EnsureUser(identity Identity) (account *UserAccount, created bool, err error)
|
||||||
|
}
|
||||||
|
|||||||
+272
-167
@@ -11,25 +11,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppRoot — корень адресов приложения.
|
// AppRoot — корень адресов приложения.
|
||||||
//
|
//
|
||||||
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
|
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
|
||||||
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
|
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
|
||||||
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
|
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
|
||||||
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
|
|
||||||
// нашим, и разойдутся они молча.
|
|
||||||
const AppRoot = "/app"
|
const AppRoot = "/app"
|
||||||
|
|
||||||
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||||||
@@ -55,17 +48,23 @@ const pollBudgetShare = 8
|
|||||||
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||||||
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||||||
// которого сервис сам же ему обещал избежать.
|
// которого сервис сам же ему обещал избежать.
|
||||||
//
|
|
||||||
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
|
|
||||||
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
|
|
||||||
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
|
|
||||||
// выполняется с запасом.
|
|
||||||
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
||||||
|
|
||||||
|
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
|
||||||
|
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
|
||||||
|
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
|
||||||
|
// публичным контрактом молча.
|
||||||
|
const (
|
||||||
|
CopyParam = "copy"
|
||||||
|
CopyOriginal = "original"
|
||||||
|
CopyNormalized = "normalized"
|
||||||
|
)
|
||||||
|
|
||||||
type AppHandler struct {
|
type AppHandler struct {
|
||||||
recordRepo contract.AudioRecordRepository
|
recordRepo contract.AudioRecordRepository
|
||||||
textRepo contract.TextRepository
|
textRepo contract.TextRepository
|
||||||
structureRepo contract.StructureRepository
|
structureRepo contract.StructureRepository
|
||||||
|
fileRepo contract.FileRepository
|
||||||
trsService *service.TranscribeService
|
trsService *service.TranscribeService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -74,6 +73,7 @@ func NewAppHandler(
|
|||||||
recordRepo contract.AudioRecordRepository,
|
recordRepo contract.AudioRecordRepository,
|
||||||
textRepo contract.TextRepository,
|
textRepo contract.TextRepository,
|
||||||
structureRepo contract.StructureRepository,
|
structureRepo contract.StructureRepository,
|
||||||
|
fileRepo contract.FileRepository,
|
||||||
trsService *service.TranscribeService,
|
trsService *service.TranscribeService,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *AppHandler {
|
) *AppHandler {
|
||||||
@@ -84,6 +84,7 @@ func NewAppHandler(
|
|||||||
recordRepo: recordRepo,
|
recordRepo: recordRepo,
|
||||||
textRepo: textRepo,
|
textRepo: textRepo,
|
||||||
structureRepo: structureRepo,
|
structureRepo: structureRepo,
|
||||||
|
fileRepo: fileRepo,
|
||||||
trsService: trsService,
|
trsService: trsService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
@@ -95,8 +96,11 @@ func NewAppHandler(
|
|||||||
//
|
//
|
||||||
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||||||
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||||||
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
|
// признак остановки не говорит человеку, чего ждать.
|
||||||
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
|
//
|
||||||
|
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
|
||||||
|
// выводится из рубежа записи, который карточка несёт и так. Второе поле
|
||||||
|
// повторяло бы рубеж и разошлось бы с ним молча.
|
||||||
type RecordView struct {
|
type RecordView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
@@ -120,11 +124,6 @@ type RecordView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||||||
//
|
|
||||||
// Место под признак заведено вперёд и заполняется другой задачей. Форма
|
|
||||||
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
|
|
||||||
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
|
|
||||||
// форму.
|
|
||||||
type IntakeItem struct {
|
type IntakeItem struct {
|
||||||
RecordView
|
RecordView
|
||||||
Duplicate bool `json:"duplicate"`
|
Duplicate bool `json:"duplicate"`
|
||||||
@@ -161,68 +160,134 @@ type ReplicaView struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
|
// Routes — адреса приложения одним обработчиком.
|
||||||
// панели один, поэтому и роутер один.
|
//
|
||||||
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
|
||||||
app := r.Group(AppRoot)
|
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
|
||||||
|
// адресного пространства. Так область их действия выводится из объявленного
|
||||||
|
// пространства, а не перечисляется вторым списком.
|
||||||
|
//
|
||||||
|
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
|
||||||
|
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
|
||||||
|
// приложения одна.
|
||||||
|
func (h *AppHandler) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
for _, pattern := range AppRoutePatterns {
|
||||||
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
mux.HandleFunc(pattern, h.handlerOf(pattern))
|
||||||
// иначе ушли бы телом библиотеки, мимо единой формы.
|
}
|
||||||
//
|
|
||||||
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
|
||||||
// адрес выдачи файлового токена из пространства хранилища. Область его
|
|
||||||
// действия при этом выводится из **этого же** перечня адресного
|
|
||||||
// пространства — см. `underIdentifiedArea`.
|
|
||||||
app.Bind(OneErrorForm())
|
|
||||||
app.Bind(RequireUser(migrations.UsersCollection))
|
|
||||||
|
|
||||||
app.GET("/me", h.Me)
|
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
|
||||||
app.GET("/config", h.Config)
|
// же: он принадлежит корню приложения, адресом приложения не является и
|
||||||
|
// потому отвечает как неизвестный путь под ним.
|
||||||
|
//
|
||||||
|
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
|
||||||
|
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
|
||||||
|
// закрытый перечень образцов они не входят: под них подходит **всё**, что
|
||||||
|
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
|
||||||
|
mux.HandleFunc(AppRoot+"/", h.notFound)
|
||||||
|
mux.HandleFunc(AppRoot, h.notFound)
|
||||||
|
|
||||||
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
return mux
|
||||||
// заводит аудиозапись, а не кладёт файл.
|
|
||||||
//
|
|
||||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
|
||||||
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
|
|
||||||
// самой записи, а отказ по нему уходит нашей формой.
|
|
||||||
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
|
||||||
app.GET("/audiorecords", h.ListRecords)
|
|
||||||
app.GET("/audiorecords/{id}", h.GetRecord)
|
|
||||||
app.GET("/audiorecords/{id}/text", h.GetRecordText)
|
|
||||||
|
|
||||||
// Перехват «под нашим корнем такого адреса нет».
|
|
||||||
//
|
|
||||||
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
|
|
||||||
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
|
|
||||||
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
|
|
||||||
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
|
|
||||||
// под корнем приложения было бы две.
|
|
||||||
//
|
|
||||||
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
|
|
||||||
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
|
|
||||||
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
|
|
||||||
app.Any("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
|
||||||
|
// вешаются обработчики, и из него же берётся значение `http.route` для журнала.
|
||||||
|
// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону
|
||||||
|
// журнала — путь, не попавший в перечень, уехал бы в строку дословно.
|
||||||
|
const (
|
||||||
|
AppRouteMe = AppRoot + "/me"
|
||||||
|
AppRouteConfig = AppRoot + "/config"
|
||||||
|
AppRouteRecords = AppRoot + "/audiorecords"
|
||||||
|
AppRouteRecord = AppRoot + "/audiorecords/{id}"
|
||||||
|
AppRouteRecordText = AppRoot + "/audiorecords/{id}/text"
|
||||||
|
AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppRoutePatterns — тот самый перечень. Порядок значения не имеет:
|
||||||
|
// маршрутизатор выбирает образец по точности, а не по месту в списке.
|
||||||
|
var AppRoutePatterns = []string{
|
||||||
|
AppRouteMe,
|
||||||
|
AppRouteConfig,
|
||||||
|
AppRouteRecords,
|
||||||
|
AppRouteRecord,
|
||||||
|
AppRouteRecordText,
|
||||||
|
AppRouteRecordFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerOf выдаёт обработчик образца.
|
||||||
|
//
|
||||||
|
// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец
|
||||||
|
// здесь — отказ на подъёме, а не тихо не заведённый адрес.
|
||||||
|
func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc {
|
||||||
|
switch pattern {
|
||||||
|
case AppRouteMe:
|
||||||
|
return only(h.Me, http.MethodGet)
|
||||||
|
case AppRouteConfig:
|
||||||
|
return only(h.Config, http.MethodGet)
|
||||||
|
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||||
|
// заводит аудиозапись, а не кладёт файл.
|
||||||
|
case AppRouteRecords:
|
||||||
|
return h.records
|
||||||
|
case AppRouteRecord:
|
||||||
|
return only(h.GetRecord, http.MethodGet)
|
||||||
|
case AppRouteRecordText:
|
||||||
|
return only(h.GetRecordText, http.MethodGet)
|
||||||
|
case AppRouteRecordFile:
|
||||||
|
return only(h.GetRecordFile, http.MethodGet, http.MethodHead)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет")
|
||||||
|
}
|
||||||
|
|
||||||
|
// only ограничивает адрес перечнем методов.
|
||||||
|
//
|
||||||
|
// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому
|
||||||
|
// перечню, и своего значения у «метод не тот» в нём не заведено — адрес,
|
||||||
|
// которого нет для этого метода, и есть ненайденный адрес.
|
||||||
|
func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, method := range methods {
|
||||||
|
if r.Method == method {
|
||||||
|
handler(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// records — приём и список одним адресом: разница только в методе.
|
||||||
|
func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.ListRecords(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.CreateRecord(w, r)
|
||||||
|
default:
|
||||||
|
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||||
// наружу наравне с журналом.
|
// наружу наравне с журналом.
|
||||||
return e.JSON(http.StatusOK, MeView{
|
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
|
||||||
ID: e.Auth.Id,
|
|
||||||
Name: e.Auth.GetString("name"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) Config(e *core.RequestEvent) error {
|
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
|
||||||
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||||||
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||||||
// до первого отказа на записи, которую человек уже успел отправить.
|
// до первого отказа на записи, которую человек уже успел отправить.
|
||||||
return e.JSON(http.StatusOK, ConfigView{
|
writeJSON(w, http.StatusOK, ConfigView{
|
||||||
MaxRecordSizeBytes: entity.MaxRecordSize,
|
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||||||
MaxPageSize: MaxPageLimit,
|
MaxPageSize: MaxPageLimit,
|
||||||
PollIntervalMs: PollIntervalMs,
|
PollIntervalMs: PollIntervalMs,
|
||||||
@@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
file, header, err := e.Request.FormFile("audio")
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
|
// Предел тела назван числом: умолчания здесь не «без предела», а величины на
|
||||||
|
// два-три порядка меньше нужного, и оставленные как есть они отвергли бы
|
||||||
|
// штатную запись сервиса. Отказ по нему уходит нашей формой тела.
|
||||||
|
//
|
||||||
|
// Ловится он **дважды**, и это не избыточность. Объявленная длина судится
|
||||||
|
// заранее: запись, за которую сервис платить не станет, не должна попасть
|
||||||
|
// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
|
||||||
|
// длины у запроса с кусочной передачей нет вовсе.
|
||||||
|
if r.ContentLength > entity.MaxRecordSize {
|
||||||
|
fail(w, contract.ErrRecordTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize)
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("audio")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
|
// Предел тела ловит запись на чтении. Не различив этот отказ и
|
||||||
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
|
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
|
||||||
// человеку «вы не приложили файл» о записи, которую он приложил и
|
// записи, которую он приложил и которая просто больше потолка.
|
||||||
// которая просто больше потолка.
|
var tooLarge *http.MaxBytesError
|
||||||
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
|
if errors.As(err, &tooLarge) {
|
||||||
return fail(e, contract.ErrRecordTooLarge)
|
fail(w, contract.ErrRecordTooLarge)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := file.Close(); err != nil {
|
if err := file.Close(); err != nil {
|
||||||
@@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
|||||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||||
// может и позже — карточкой записи. Значения контекста (журнал запроса,
|
// может и позже — карточкой записи.
|
||||||
// сессия) при этом сохраняются, теряется только отмена.
|
ctx := context.WithoutCancel(r.Context())
|
||||||
ctx := context.WithoutCancel(e.Request.Context())
|
|
||||||
|
|
||||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
|
||||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
|
||||||
// чужое имя.
|
// чужое имя.
|
||||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||||||
// делает это одним местом — по причине отказа, а не по месту.
|
// делает это одним местом — по причине отказа, а не по месту.
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||||||
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||||||
return e.JSON(http.StatusCreated, []IntakeItem{{
|
writeJSON(w, http.StatusCreated, []IntakeItem{{
|
||||||
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||||||
// отсутствие поля и пустой перечень приложение не различит.
|
// отсутствие поля и пустой перечень приложение не различит.
|
||||||
RecordView: h.viewOf(record, nil, &[]string{}),
|
RecordView: h.viewOf(record, nil, &[]string{}),
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||||||
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}
|
||||||
|
|
||||||
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||||
limit, err := strconv.Atoi(raw)
|
limit, err := strconv.Atoi(raw)
|
||||||
if err != nil || limit <= 0 {
|
if err != nil || limit <= 0 {
|
||||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||||||
// сервис отдаёт, но просьба сама по себе не негодна.
|
// сервис отдаёт, но просьба сама по себе не негодна.
|
||||||
q.Limit = min(limit, MaxPageLimit)
|
q.Limit = min(limit, MaxPageLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
|
if raw := r.URL.Query().Get("filter"); raw != "" {
|
||||||
filter, ok := entity.ParseListFilter(raw)
|
filter, ok := entity.ParseListFilter(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
q.Filter = &filter
|
q.Filter = &filter
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
|
if raw := r.URL.Query().Get("cursor"); raw != "" {
|
||||||
cursor, err := decodeCursor(raw)
|
cursor, err := decodeCursor(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||||||
// архив, листающийся по кругу, и ни строки в журнале.
|
// архив, листающийся по кругу, и ни строки в журнале.
|
||||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
q.Cursor = cursor
|
q.Cursor = cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
page, err := h.recordRepo.List(q)
|
page, err := h.recordRepo.List(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
|
h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
names, err := h.topicNames(e.Auth.Id, page.Items)
|
names, err := h.topicNames(account.ID, page.Items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
|
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
||||||
@@ -331,55 +421,71 @@ func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
|||||||
view.NextCursor = &encoded
|
view.NextCursor = &encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, view)
|
writeJSON(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
|
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
record, err := h.readOwn(e)
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
|
record, err := h.readOwn(r, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
|
names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
views := h.availableViews(record)
|
views := h.availableViews(record)
|
||||||
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
|
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
|
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
|
||||||
view := e.Request.URL.Query().Get("view")
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
|
view := r.URL.Query().Get("view")
|
||||||
if !entity.IsKnownTextView(view) {
|
if !entity.IsKnownTextView(view) {
|
||||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := h.readOwn(e)
|
record, err := h.readOwn(r, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if view == entity.TextViewReplicas {
|
if view == entity.TextViewReplicas {
|
||||||
return h.replicasOf(e, record)
|
h.replicasOf(w, record)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return h.plainTextOf(e, record, view)
|
h.plainTextOf(w, record, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
|
// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
|
||||||
// одним и тем же: по разнице ответов иначе перебирается список заведённых
|
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
|
||||||
// записей.
|
// иначе перебирается список заведённых записей.
|
||||||
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
|
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
|
||||||
recordID := e.Request.PathValue("id")
|
// Идентификатор разбирается на границе: он приходит от спрашивающего, а
|
||||||
|
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
|
||||||
|
// одной строкой. Негодный по виду считается несуществующим и до базы не
|
||||||
|
// доходит вовсе.
|
||||||
|
recordID, ok := ident.Parse(r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||||
|
}
|
||||||
|
|
||||||
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
record, err := h.recordRepo.GetByID(recordID, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
|
||||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
|
||||||
// аварии не узнаёт ниоткуда.
|
// узнаёт ниоткуда.
|
||||||
var notFound *contract.JobNotFoundError
|
var notFound *contract.JobNotFoundError
|
||||||
if !errors.As(err, ¬Found) {
|
if !errors.As(err, ¬Found) {
|
||||||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||||||
@@ -389,52 +495,62 @@ func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error)
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
|
func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
|
||||||
textID := record.TranscriptTextID
|
textID := record.TranscriptTextID
|
||||||
if view == entity.TextViewLiterary {
|
if view == entity.TextViewLiterary {
|
||||||
textID = record.LiteraryTextID
|
textID = record.LiteraryTextID
|
||||||
}
|
}
|
||||||
|
|
||||||
if textID == nil {
|
if textID == nil {
|
||||||
return fail(e, contract.ErrTextNotReady)
|
fail(w, contract.ErrTextNotReady)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text, err := h.textRepo.GetByID(*textID)
|
text, err := h.textRepo.GetByID(*textID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if text.Contents == "" {
|
if text.Contents == "" {
|
||||||
return fail(e, contract.ErrTextNotReady)
|
fail(w, contract.ErrTextNotReady)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
|
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
|
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
|
||||||
if record.StructureID == nil {
|
if record.StructureID == nil {
|
||||||
return fail(e, contract.ErrTextNotReady)
|
fail(w, contract.ErrTextNotReady)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len(structure.Replicas) == 0 {
|
if len(structure.Replicas) == 0 {
|
||||||
return fail(e, contract.ErrTextNotReady)
|
fail(w, contract.ErrTextNotReady)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||||||
for _, r := range structure.Replicas {
|
for _, replica := range structure.Replicas {
|
||||||
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
|
replicas = append(replicas, ReplicaView{
|
||||||
|
StartMs: replica.StartMs,
|
||||||
|
EndMs: replica.EndMs,
|
||||||
|
Text: replica.Text,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||||
}
|
}
|
||||||
|
|
||||||
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
||||||
// сотню записей иначе стоила бы сотни обращений к хранилищу.
|
// сотню записей иначе стоила бы сотни обращений к базе.
|
||||||
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
@@ -481,9 +597,7 @@ func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string,
|
|||||||
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||||||
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||||||
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
|
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
|
||||||
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
|
|
||||||
// из которой текст «вот-вот появится».
|
|
||||||
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||||
views := []string{}
|
views := []string{}
|
||||||
|
|
||||||
@@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
|||||||
|
|
||||||
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||||||
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||||||
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
|
// из-за неё карточку хуже, чем недосказать.
|
||||||
// журналом, который пишет чтение текста.
|
|
||||||
func (h *AppHandler) hasText(textID *string) bool {
|
func (h *AppHandler) hasText(textID *string) bool {
|
||||||
if textID == nil {
|
if textID == nil {
|
||||||
return false
|
return false
|
||||||
@@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool {
|
|||||||
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||||||
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||||||
//
|
//
|
||||||
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
|
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
|
||||||
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
|
// телом.
|
||||||
// ключ уезжает параметром, а не телом.
|
|
||||||
func encodeCursor(c *contract.RecordCursor) string {
|
func encodeCursor(c *contract.RecordCursor) string {
|
||||||
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
|
return base64.RawURLEncoding.EncodeToString(
|
||||||
|
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||||
@@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||||||
if !ok || createdAt == "" || id == "" {
|
if !ok {
|
||||||
return nil, errors.New("malformed cursor")
|
return nil, errors.New("malformed cursor")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
|
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
|
||||||
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
|
// запрос сравнением, а идентификатор — точным совпадением, и негодная
|
||||||
// обращает условие в постоянную истину или ложь — человек получает либо
|
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
|
||||||
// пустой архив при непустом счётчике, либо ленту с начала.
|
// ленту с начала.
|
||||||
parsed, err := types.ParseDateTime(createdAt)
|
parsed, err := time.Parse(time.RFC3339, createdAt)
|
||||||
if err != nil || parsed.IsZero() {
|
if err != nil {
|
||||||
return nil, errors.New("cursor carries no readable time")
|
return nil, errors.New("cursor carries no readable time")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
|
recordID, valid := ident.Parse(id)
|
||||||
}
|
if !valid {
|
||||||
|
return nil, errors.New("cursor carries no readable record key")
|
||||||
|
}
|
||||||
|
|
||||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
|
||||||
// всех одна, а сказать человеку надо разное.
|
|
||||||
func errWithMessage(base error, message string) error {
|
|
||||||
return &messagedError{base: base, message: message}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type messagedError struct {
|
|
||||||
base error
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *messagedError) Error() string { return e.message }
|
|
||||||
func (e *messagedError) Unwrap() error { return e.base }
|
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
|
||||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||||
|
|
||||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||||
@@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
assert.NotContains(t, w.Body.String(), "job_id")
|
assert.NotContains(t, w.Body.String(), "job_id")
|
||||||
|
|
||||||
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
|
// Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
|
||||||
// хранилище.
|
// каталог данных.
|
||||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
assert.Equal(t, 0, countFiles(t, env))
|
||||||
require.NoError(t, err)
|
assert.Equal(t, 0, countJobs(t, env))
|
||||||
assert.Empty(t, files)
|
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, jobs)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
|
w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
env.mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
assert.NotContains(t, w.Body.String(), "transcription_text")
|
|
||||||
assert.NotContains(t, w.Body.String(), "created_at")
|
assert.NotContains(t, w.Body.String(), "created_at")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||||
// не перебирается список заведённых задач.
|
// не перебирается список заведённых записей.
|
||||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
|||||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||||
require.Equal(t, http.StatusCreated, created.Code)
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
|
||||||
require.NoError(t, err)
|
missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||||
require.Len(t, jobs, 1)
|
|
||||||
|
|
||||||
existing := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))
|
|
||||||
|
|
||||||
missing := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, existing.Code)
|
assert.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||||
assert.Equal(t, missing.Code, existing.Code)
|
assert.Equal(t, missing.Code, existing.Code)
|
||||||
|
assert.Equal(t, missing.Body.String(), existing.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
|
// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
|
||||||
//
|
// первым обращением и находится вторым, а строка её в базе одна.
|
||||||
// Два запроса подряд с одним значением заголовка: учётная запись заводится
|
|
||||||
// первым и находится вторым, а в хранилище её строка одна.
|
|
||||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
|||||||
|
|
||||||
before := countAccounts(t, env)
|
before := countAccounts(t, env)
|
||||||
|
|
||||||
first := httptest.NewRecorder()
|
first := env.getAs(login, "/app/me")
|
||||||
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
|
||||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||||
|
|
||||||
second := httptest.NewRecorder()
|
second := env.getAs(login, "/app/me")
|
||||||
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
|
||||||
require.Equal(t, http.StatusOK, second.Code)
|
require.Equal(t, http.StatusOK, second.Code)
|
||||||
|
|
||||||
assert.Equal(t, before+1, countAccounts(t, env),
|
assert.Equal(t, before+1, countAccounts(t, env),
|
||||||
@@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
|||||||
"второе обращение попало в другую учётную запись")
|
"второе обращение попало в другую учётную запись")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
|
// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
|
||||||
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
|
// даёт отказ, а не вход под названным именем.
|
||||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
|||||||
"заголовок с недоверенного адреса завёл учётную запись")
|
"заголовок с недоверенного адреса завёл учётную запись")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
|
// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
|
||||||
|
// не существует.
|
||||||
//
|
//
|
||||||
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
|
// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
|
||||||
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
|
// собственные входы, перечисление коллекции пользователей, правка своей строки —
|
||||||
// из них работает, узнавание по заголовку обходится двумя запросами.
|
// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
|
||||||
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
|
// они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
|
||||||
|
//
|
||||||
|
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
|
||||||
|
// число учётных записей после обхода то же самое.
|
||||||
|
func TestStorageAddressSpaceIsGone(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
const usersRoot = "/api/collections/users"
|
before := countAccounts(t, env)
|
||||||
|
|
||||||
cases := []struct {
|
// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
|
||||||
name string
|
reference := env.get("/nothing-was-ever-here")
|
||||||
path string
|
require.Equal(t, http.StatusOK, reference.Code)
|
||||||
body string
|
|
||||||
}{
|
cases := map[string]string{
|
||||||
{
|
"перечисление учётных записей": "/api/collections/users/records",
|
||||||
name: "завести учётную запись самому",
|
"вход по паролю": "/api/collections/users/auth-with-password",
|
||||||
path: usersRoot + "/records",
|
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
|
||||||
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
|
"выдача токена файла": "/api/files/token",
|
||||||
},
|
"адрес панели": "/_/",
|
||||||
{
|
"адрес панели знаком кода": "/%5f/",
|
||||||
name: "вход по паролю",
|
|
||||||
path: usersRoot + "/auth-with-password",
|
|
||||||
body: `{"identity":"person@example.com","password":"whatever"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "обмен кода у внешнего провайдера",
|
|
||||||
path: usersRoot + "/auth-with-oauth2",
|
|
||||||
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "вход по одноразовому коду",
|
|
||||||
path: usersRoot + "/auth-with-otp",
|
|
||||||
body: `{"otpId":"whatever","password":"whatever"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "запрос одноразового кода",
|
|
||||||
path: usersRoot + "/request-otp",
|
|
||||||
body: `{"email":"person@example.com"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "восстановление пароля",
|
|
||||||
path: usersRoot + "/request-password-reset",
|
|
||||||
body: `{"email":"person@example.com"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "продление сессии",
|
|
||||||
path: usersRoot + "/auth-refresh",
|
|
||||||
body: `{}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cases {
|
for name, path := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
before := countAccounts(t, env)
|
w := env.getOwn(path)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
|
assert.Equal(t, reference.Code, w.Code,
|
||||||
req.Header.Set("Content-Type", "application/json")
|
"адрес %s отвечает не как всякий неизвестный путь", path)
|
||||||
|
assert.Equal(t, reference.Body.String(), w.Body.String(),
|
||||||
w := httptest.NewRecorder()
|
"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
|
||||||
env.mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
|
||||||
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
|
|
||||||
assert.NotContains(t, w.Body.String(), `"token"`,
|
assert.NotContains(t, w.Body.String(), `"token"`,
|
||||||
"адрес %s выдал значение доступа", c.path)
|
"адрес %s выдал значение доступа", path)
|
||||||
assert.Equal(t, before, countAccounts(t, env),
|
|
||||||
"адрес %s изменил число учётных записей", c.path)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, before, countAccounts(t, env),
|
||||||
|
"обход прежнего пространства хранилища изменил число учётных записей")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
|
// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
|
||||||
|
// заведения самим сервисом.
|
||||||
//
|
//
|
||||||
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
|
// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
|
||||||
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
|
// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
|
||||||
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
|
// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
|
||||||
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
|
// правившей запись библиотечным правилом, не осталось.
|
||||||
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
|
func TestAccountKeyHasNoEditAddress(t *testing.T) {
|
||||||
// запись вместе со всем архивом.
|
|
||||||
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
|
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
|
body := strings.NewReader(`{"provider_login":"victim"}`)
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
|
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
|
// Метод правки до раздачи приложения не доходит: она открывает страницу
|
||||||
|
// только на `GET` и `HEAD`.
|
||||||
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
|
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
||||||
require.NoError(t, err)
|
assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
|
||||||
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
|
|
||||||
"ключ учётной записи переписан снаружи")
|
"ключ учётной записи переписан снаружи")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
|
|
||||||
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
|
|
||||||
// значения, которыми довольно назваться.
|
|
||||||
func TestUserRecordsCannotBeListed(t *testing.T) {
|
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(w, asUser(
|
|
||||||
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
|
||||||
assert.NotContains(t, w.Body.String(), env.login)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||||
// ничего не заводит.
|
// ничего не заводит.
|
||||||
//
|
//
|
||||||
@@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
|||||||
"пустое значение": "",
|
"пустое значение": "",
|
||||||
"одни пробелы": " ",
|
"одни пробелы": " ",
|
||||||
"управляющий знак": "ali\x00ce",
|
"управляющий знак": "ali\x00ce",
|
||||||
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
|
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range cases {
|
for name, value := range cases {
|
||||||
@@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
|||||||
assert.Equal(t, before, countAccounts(t, env))
|
assert.Equal(t, before, countAccounts(t, env))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
|
// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
|
||||||
// заголовок, а протухший узнаванию не мешает.
|
|
||||||
//
|
//
|
||||||
// Первая половина защищает владельца панели: подмена его учётной записью
|
// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
|
||||||
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
|
// пришедшим считается названный заголовком. Прежде такое значение било заголовок:
|
||||||
// человека: негодный токен, оставшийся в браузере, не должен запирать его
|
// им пользовался владелец панели, а панели больше нет.
|
||||||
// снаружи.
|
func TestPresentedValueIsNotAccepted(t *testing.T) {
|
||||||
func TestStorageTokenBeatsHeader(t *testing.T) {
|
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
stranger, strangerLogin := newSecondAccount(t, env.app)
|
stranger := newSecondAccount(t, env)
|
||||||
token, err := stranger.NewAuthToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Run("годный токен побеждает", func(t *testing.T) {
|
req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
req.Header.Set("Authorization", "Bearer whatever")
|
||||||
req.Header.Set("Authorization", token)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), stranger.Id,
|
assert.Contains(t, w.Body.String(), env.account.ID,
|
||||||
"заголовок победил предъявленный токен")
|
"предъявленное значение победило заголовок")
|
||||||
_ = strangerLogin
|
assert.NotContains(t, w.Body.String(), stranger.ID)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
||||||
req.Header.Set("Authorization", "not-a-token")
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), env.account.Id)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
|
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному,
|
||||||
// неузнанному, а заголовок на них учётной записи не заводит.
|
// а заголовок на них учётной записи не заводит.
|
||||||
//
|
//
|
||||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||||
// такой запрос с новым именем — записи в неё.
|
// такой запрос с новым именем — записи в неё.
|
||||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||||
app := newTestStorage(t)
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
r, err := apis.NewRouter(app)
|
before := countAccounts(t, env)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
|
for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
|
||||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
anonymous := env.get(path)
|
||||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
|
|
||||||
|
|
||||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
|
||||||
return e.String(http.StatusOK, "# metrics")
|
|
||||||
})
|
|
||||||
|
|
||||||
mux, err := r.BuildMux()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, path := range []string{HealthPath, MetricsPath} {
|
|
||||||
anonymous := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
|
|
||||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||||
|
|
||||||
withHeader := httptest.NewRecorder()
|
withHeader := env.getAs("passerby", path)
|
||||||
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
|
|
||||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
assert.Equal(t, before, countAccounts(t, env),
|
||||||
require.NoError(t, err)
|
"обращение к открытому адресу завело учётную запись")
|
||||||
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||||
//
|
//
|
||||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||||
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
|
// суток, отозванный у провайдера человек работал до его истечения.
|
||||||
// кука вернула бы и это.
|
|
||||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := env.getOwn("/app/me")
|
||||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
|
// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
|
||||||
// печатается ничто, что даёт доступ.
|
|
||||||
//
|
//
|
||||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||||
@@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) {
|
|||||||
|
|
||||||
assert.NotContains(t, journal, env.login,
|
assert.NotContains(t, journal, env.login,
|
||||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||||
assert.NotContains(t, journal, env.account.Email(),
|
assert.NotContains(t, journal, "person@example.com",
|
||||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||||
// виден **адресом пира**, а не значением заголовка.
|
// виден **адресом пира**, а не значением заголовка.
|
||||||
//
|
|
||||||
// Без этой строки владелец, у которого никто не может войти, не отличит своей
|
|
||||||
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
|
|
||||||
// ставит) — а это разные поломки в разных местах.
|
|
||||||
func TestUntrustedPeerIsLogged(t *testing.T) {
|
func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) {
|
|||||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
|
||||||
func TestRecordFileIsProtected(t *testing.T) {
|
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
|
||||||
app := newTestStorage(t)
|
// обработчика они неразличимы.
|
||||||
|
|
||||||
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.True(t, field.Protected,
|
|
||||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
|
|
||||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
|
||||||
// ссылке.
|
|
||||||
func TestRecordFileNeedsToken(t *testing.T) {
|
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
|
||||||
|
|
||||||
created := httptest.NewRecorder()
|
|
||||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
|
||||||
require.Equal(t, http.StatusCreated, created.Code)
|
|
||||||
|
|
||||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, files, 1)
|
|
||||||
|
|
||||||
names := files[0].GetStringSlice("file")
|
|
||||||
require.Len(t, names, 1)
|
|
||||||
|
|
||||||
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
|
||||||
|
|
||||||
anonymous := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
|
||||||
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
|
|
||||||
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
|
||||||
// содержимое кому угодно — знание ссылки и было доступом.
|
|
||||||
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
|
||||||
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
|
|
||||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
|
||||||
|
|
||||||
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
|
|
||||||
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
|
|
||||||
// браузера вовсе.
|
|
||||||
withToken := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(withToken,
|
|
||||||
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
|
|
||||||
require.Equal(t, http.StatusOK, withToken.Code)
|
|
||||||
assert.Equal(t, "audio content", withToken.Body.String())
|
|
||||||
|
|
||||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
|
||||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
|
||||||
reader, err := fileRepo.Open(files[0].Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() {
|
|
||||||
assert.NoError(t, reader.Close())
|
|
||||||
}()
|
|
||||||
|
|
||||||
content := make([]byte, len("audio content"))
|
|
||||||
_, err = reader.Read(content)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "audio content", string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
|
|
||||||
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
|
|
||||||
// ответу обработчика они неразличимы.
|
|
||||||
func countAccounts(t *testing.T, env *testEnv) int {
|
func countAccounts(t *testing.T, env *testEnv) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
records, err := env.app.FindAllRecords(migrations.UsersCollection)
|
return countRows(t, env, "users")
|
||||||
require.NoError(t, err)
|
}
|
||||||
|
|
||||||
return len(records)
|
// accountLogin читает ключ учётной записи прямо из базы.
|
||||||
|
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var login string
|
||||||
|
require.NoError(t, env.db.Reader().
|
||||||
|
QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
|
||||||
|
return login
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
||||||
// запрос не заводит учётной записи.
|
// запрос не заводит учётной записи.
|
||||||
//
|
//
|
||||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||||
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
|
// ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет
|
||||||
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
|
// выбирается, следующие запросы получают отказ — и заводят учётные записи.
|
||||||
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
|
// Убрать их потом нечем: учётная запись с записями не удаляется.
|
||||||
// не удаляется, а мусорная растёт в той же единственной базе.
|
|
||||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
|
||||||
|
|
||||||
before := countAccounts(t, env)
|
before := countAccounts(t, env)
|
||||||
|
|
||||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||||
// потолок раньше, чем начнутся новые имена.
|
// потолок раньше, чем начнутся новые имена.
|
||||||
for range appRateMaxRequests + 5 {
|
for range appRateMaxRequests + 5 {
|
||||||
w := httptest.NewRecorder()
|
env.getOwn("/app/me")
|
||||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rejected := 0
|
rejected := 0
|
||||||
for i := range 20 {
|
for i := range 20 {
|
||||||
w := httptest.NewRecorder()
|
w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
|
||||||
env.mux.ServeHTTP(w, asUser(
|
|
||||||
httptest.NewRequest(http.MethodGet, "/app/me", nil),
|
|
||||||
fmt.Sprintf("newcomer-%d", i)))
|
|
||||||
if w.Code == http.StatusTooManyRequests {
|
if w.Code == http.StatusTooManyRequests {
|
||||||
rejected++
|
rejected++
|
||||||
}
|
}
|
||||||
@@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||||
//
|
|
||||||
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
|
|
||||||
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
|
|
||||||
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
|
|
||||||
// человеком.
|
|
||||||
func TestAccountCreationIsLogged(t *testing.T) {
|
func TestAccountCreationIsLogged(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
const login = "brand-new-person"
|
const login = "brand-new-person"
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := env.getAs(login, "/app/me")
|
||||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
journal := env.journal.String()
|
journal := env.journal.String()
|
||||||
@@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
|||||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||||
before := strings.Count(journal, "Account created from login header")
|
before := strings.Count(journal, "Account created from login header")
|
||||||
|
|
||||||
again := httptest.NewRecorder()
|
again := env.getAs(login, "/app/me")
|
||||||
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
|
||||||
require.Equal(t, http.StatusOK, again.Code)
|
require.Equal(t, http.StatusOK, again.Code)
|
||||||
|
|
||||||
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
||||||
@@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||||
//
|
|
||||||
// Два значения заголовка означают прокси, который его добавляет вместо замены,
|
|
||||||
// — модель угроз называет это главным барьером. Отладочным уровнем такая
|
|
||||||
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
|
|
||||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
|||||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
|
// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
|
||||||
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
// кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||||
//
|
//
|
||||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
|
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
|
||||||
// то, что сломано не у него.
|
// что сломано не у него.
|
||||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
|
// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
|
||||||
// же, как она перестанет работать при отказе хранилища.
|
// же, как она перестанет работать при отказе базы.
|
||||||
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
|
_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
users.RemoveIndex("idx_users_provider_login")
|
|
||||||
users.Fields.RemoveByName(migrations.ProviderLoginField)
|
|
||||||
require.NoError(t, env.app.Save(users))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := env.getAs("somebody", "/app/me")
|
||||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
||||||
"отказ хранилища выдан за «вас не узнали»")
|
"отказ базы выдан за «вас не узнали»")
|
||||||
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
|||||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||||
// поведение.
|
// поведение.
|
||||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||||
env := setupWebappEnv(t, builtDist(), true)
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
res := env.get("/auth/login")
|
res := env.get("/auth/login")
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
@@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
|||||||
var me MeView
|
var me MeView
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||||
|
|
||||||
assert.Equal(t, env.account.Id, me.ID)
|
assert.Equal(t, env.account.ID, me.ID)
|
||||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||||
"адрес почты принадлежит человеку, а не сервису")
|
"адрес почты принадлежит человеку, а не сервису")
|
||||||
}
|
}
|
||||||
@@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
|||||||
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||||
|
|
||||||
unknown := httptest.NewRecorder()
|
unknown := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody))
|
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))
|
||||||
|
|
||||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||||
@@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
|||||||
|
|
||||||
item := intakeItemOf(t, w)
|
item := intakeItemOf(t, w)
|
||||||
|
|
||||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NotNil(t, record.OriginalFilename)
|
require.NotNil(t, record.OriginalFilename)
|
||||||
@@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
|||||||
|
|
||||||
item := intakeItemOf(t, w)
|
item := intakeItemOf(t, w)
|
||||||
|
|
||||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, record.OriginalFilename)
|
require.NotNil(t, record.OriginalFilename)
|
||||||
|
|
||||||
@@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "записи нет",
|
name: "записи нет",
|
||||||
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) },
|
req: func() *http.Request {
|
||||||
|
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
|
||||||
|
},
|
||||||
code: CodeNotFound,
|
code: CodeNotFound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Своё правило ограничителя частоты заведено под корнем приложения: правило
|
// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
|
||||||
// хранилища настроено на его собственный корень и наших адресов не покрывает.
|
// наблюдения: правило своё, и настроено оно на корень приложения.
|
||||||
func TestRateLimitRuleCoversAppRoot(t *testing.T) {
|
func TestRateLimitCoversAppRootOnly(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
var refused *httptest.ResponseRecorder
|
||||||
|
for range appRateMaxRequests + 1 {
|
||||||
var found int
|
refused = env.getOwn("/app/me")
|
||||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
if refused.Code == http.StatusTooManyRequests {
|
||||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
break
|
||||||
found++
|
|
||||||
assert.Positive(t, rule.MaxRequests)
|
|
||||||
assert.Positive(t, rule.Duration)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
|
require.Equal(t, http.StatusTooManyRequests, refused.Code,
|
||||||
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
|
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
|
||||||
|
|
||||||
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом
|
// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
|
||||||
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу,
|
// приложения, а слои одеты на корень.
|
||||||
// и правила молча копились бы с каждой выкладкой.
|
assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
|
||||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
"ограничитель приложения закрыл наблюдение за сервисом")
|
||||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
}
|
||||||
|
|
||||||
again := 0
|
// Два клиентских адреса через один доверенный прокси расходуют **разные**
|
||||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
|
||||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
// влияет.
|
||||||
again++
|
//
|
||||||
|
// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
|
||||||
|
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
|
||||||
|
// ограничителя ровно тому, кого он ограничивает.
|
||||||
|
func TestRateLimitKeyNamesTheClient(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
spend := func(peer, forwarded string) int {
|
||||||
|
refused := 0
|
||||||
|
for range appRateMaxRequests + 1 {
|
||||||
|
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||||
|
req.Header.Set(LoginHeader, env.login)
|
||||||
|
req.RemoteAddr = peer
|
||||||
|
if forwarded != "" {
|
||||||
|
req.Header.Set(ForwardedForHeader, forwarded)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый клиент выбирает свой бюджет целиком.
|
||||||
|
require.Positive(t, spend(trustedPeer, "198.51.100.7"),
|
||||||
|
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||||
|
|
||||||
|
// Второй клиент за тем же прокси начинает со своего.
|
||||||
|
firstRefusalOfSecond := 0
|
||||||
|
for range appRateMaxRequests {
|
||||||
|
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||||
|
req.Header.Set(LoginHeader, env.login)
|
||||||
|
req.RemoteAddr = trustedPeer
|
||||||
|
req.Header.Set(ForwardedForHeader, "198.51.100.8")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusTooManyRequests {
|
||||||
|
firstRefusalOfSecond++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе")
|
assert.Zero(t, firstRefusalOfSecond,
|
||||||
|
"исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
|
||||||
|
// влияет: иначе спрашивающий назначал бы себе ключ счётчика сам и обходил
|
||||||
|
// ограничитель, меняя значение.
|
||||||
|
func TestRateLimitIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
refused := 0
|
||||||
|
for i := range appRateMaxRequests + 10 {
|
||||||
|
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||||
|
req.RemoteAddr = untrustedPeer
|
||||||
|
req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Positive(t, refused,
|
||||||
|
"меняя заголовок пересылки, спрашивающий обошёл ограничитель")
|
||||||
|
}
|
||||||
|
|
||||||
|
// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя.
|
||||||
|
// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и
|
||||||
|
// проверке нужен каждый их вид — одна строка, несколько строк, цепочка.
|
||||||
|
func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int {
|
||||||
|
refused := 0
|
||||||
|
for i := range count {
|
||||||
|
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||||
|
req.Header.Set(LoginHeader, env.login)
|
||||||
|
req.RemoteAddr = peer
|
||||||
|
for _, value := range forwarded(i) {
|
||||||
|
req.Header.Add(ForwardedForHeader, value)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение, которое приписал сам спрашивающий, ключа бюджета не задаёт.
|
||||||
|
//
|
||||||
|
// Прокси заголовок **дописывает**, а не заменяет: слева в цепочке стоит то, что
|
||||||
|
// прислал аноним, а справа — адрес, который приписал прокси. Ключ, взятый слева,
|
||||||
|
// менялся бы на каждом запросе, и бюджет обходился бы с первого.
|
||||||
|
func TestRateLimitIgnoresValuePresentedByTheClient(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||||
|
return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Positive(t, refused,
|
||||||
|
"подставляя своё значение слева, спрашивающий обошёл ограничитель")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цепочка законно приходит несколькими строками заголовка, и читаются они все:
|
||||||
|
// разбор одной строки увидел бы кусок, которым распоряжается аноним.
|
||||||
|
func TestRateLimitReadsEveryForwardedHeaderLine(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||||
|
return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Positive(t, refused,
|
||||||
|
"вторая строка заголовка не прочитана: ключ достался присланному значению")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доверенные шаги цепочки отбрасываются, и ключом становится первый недоверенный
|
||||||
|
// справа. Два клиента за одним прокси при этом расходуют разные бюджеты.
|
||||||
|
func TestRateLimitSkipsTrustedHopsFromTheRight(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
require.Positive(t,
|
||||||
|
spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string {
|
||||||
|
return []string{"203.0.113.11, 10.9.9.9"}
|
||||||
|
}),
|
||||||
|
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||||
|
|
||||||
|
assert.Zero(t,
|
||||||
|
spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string {
|
||||||
|
return []string{"203.0.113.12, 10.9.9.9"}
|
||||||
|
}),
|
||||||
|
"исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тип содержимого ответа выбирает сервис, а не отправитель.
|
||||||
|
//
|
||||||
|
// Расширение приходит из имени, данное отправителем: `запись.html`, отданный
|
||||||
|
// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип
|
||||||
|
// выводится поэтому из **закрытого** перечня известных форматов — той же единой
|
||||||
|
// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream`
|
||||||
|
// на сохранение.
|
||||||
|
func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
fetch := func(t *testing.T, name string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, name, []byte("данные")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code, name)
|
||||||
|
|
||||||
|
return env.getOwn("/app/audiorecords/" +
|
||||||
|
intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) {
|
||||||
|
for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} {
|
||||||
|
w := fetch(t, name)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, name)
|
||||||
|
header := w.Result().Header
|
||||||
|
assert.Equal(t, unknownContentType, header.Get("Content-Type"), name)
|
||||||
|
assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("известный формат отдаётся своим типом", func(t *testing.T) {
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"запись.mkv": "video/x-matroska",
|
||||||
|
"запись.mov": "video/quicktime",
|
||||||
|
"запись.avi": "video/x-msvideo",
|
||||||
|
"запись.mp3": "audio/mpeg",
|
||||||
|
} {
|
||||||
|
w := fetch(t, name)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, name)
|
||||||
|
header := w.Result().Header
|
||||||
|
assert.Equal(t, want, header.Get("Content-Type"), name)
|
||||||
|
assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечень типов содержимого сверяется с перечнем известных форматов
|
||||||
|
// механически: формат, объявленный диалогу выбора файла и оставшийся без типа,
|
||||||
|
// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы
|
||||||
|
// загрузить то, что потом не умеет показать.
|
||||||
|
func TestEveryKnownFormatHasContentType(t *testing.T) {
|
||||||
|
formats := metrics.PublicFormats()
|
||||||
|
require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать")
|
||||||
|
|
||||||
|
for _, format := range formats {
|
||||||
|
contentType, disposition := presentationOf(format)
|
||||||
|
assert.NotEqual(t, unknownContentType, contentType,
|
||||||
|
"формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format)
|
||||||
|
assert.Equal(t, dispositionInline, disposition, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
for format := range contentTypes {
|
||||||
|
assert.Equal(t, format, metrics.FormatLabel(format),
|
||||||
|
"тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше.
|
||||||
|
//
|
||||||
|
// Слой восстановления — верхняя граница поверхности, и проверяется он через
|
||||||
|
// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому
|
||||||
|
// собранная иначе поверхность судила бы не то. Без него один паникующий запрос
|
||||||
|
// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил.
|
||||||
|
func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
|
||||||
|
db, _, _ := newTestStorage(t)
|
||||||
|
users := sqliterepo.NewUserRepository(db)
|
||||||
|
|
||||||
|
journal := &journalBuffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic("шаг обработчика упал")
|
||||||
|
})
|
||||||
|
mounts := ServiceMounts(
|
||||||
|
AppChain(panicking, users, testTrustedNetworks(t), logger),
|
||||||
|
http.NotFoundHandler(),
|
||||||
|
)
|
||||||
|
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||||
|
|
||||||
|
account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, account.ID)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
var body ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
|
||||||
|
assert.Equal(t, CodeInternal, body.Code)
|
||||||
|
assert.NotEmpty(t, body.Message)
|
||||||
|
assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
|
||||||
|
"значение паники ушло спрашивающему")
|
||||||
|
|
||||||
|
assert.Contains(t, journal.String(), "Handler panicked",
|
||||||
|
"владелец сервиса о панике не узнал")
|
||||||
|
|
||||||
|
// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
|
||||||
|
alive := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
|
||||||
|
assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||||
@@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
structures := pbrepo.NewStructureRepository(env.app)
|
structures := env.handler.structureRepo
|
||||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) {
|
|||||||
// частом отказе после превышения размера.
|
// частом отказе после превышения размера.
|
||||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
|
||||||
|
|
||||||
var last *httptest.ResponseRecorder
|
var last *httptest.ResponseRecorder
|
||||||
for range appRateMaxRequests + 1 {
|
for range appRateMaxRequests + 1 {
|
||||||
@@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
|||||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||||
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFailuresBornOutsideHandlerShareOneForm — **критерий приёмки**: отказы,
|
||||||
|
// рождающиеся не в обработчике, приходят той же формой, что и отказы
|
||||||
|
// обработчика.
|
||||||
|
//
|
||||||
|
// Проверка идёт **настоящими** HTTP-запросами через поднятую цепочку слоёв:
|
||||||
|
// вызовом отображателя ошибки это не проверяется — ни один из трёх отказов до
|
||||||
|
// него не доходит. Предел тела ловит запрос слоем чтения, ограничитель частоты —
|
||||||
|
// слоем перед узнаванием, неизвестный путь — маршрутизатором.
|
||||||
|
func TestFailuresBornOutsideHandlerShareOneForm(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
server := env.liveServer(t)
|
||||||
|
|
||||||
|
// Перечень кодов закрыт: значение вне его приложению разбирать нечем.
|
||||||
|
known := map[string]bool{
|
||||||
|
CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true,
|
||||||
|
CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true,
|
||||||
|
CodeInternal: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOurForm := func(t *testing.T, status int, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body)
|
||||||
|
require.Contains(t, raw, "error_code")
|
||||||
|
require.Contains(t, raw, "message")
|
||||||
|
|
||||||
|
code, ok := raw["error_code"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, known[code], "код отказа %q вне закрытого перечня", code)
|
||||||
|
assert.NotEmpty(t, raw["message"])
|
||||||
|
assert.NotEqual(t, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("предел тела", func(t *testing.T) {
|
||||||
|
// Запрос идёт **сырым соединением**: клиент стандартной библиотеки
|
||||||
|
// отказывается слать объявленную длину, которой не соответствует тело, а
|
||||||
|
// прислать восемь гигабайт на самом деле проверка не может. Сервер при
|
||||||
|
// этом видит обычный запрос: заголовки разобраны, длина объявлена, тела
|
||||||
|
// он не читает вовсе — отказ наступает раньше.
|
||||||
|
status, body := rawRequest(t, server, ""+
|
||||||
|
"POST /app/audiorecords HTTP/1.1\r\n"+
|
||||||
|
"Host: transcriber.test\r\n"+
|
||||||
|
LoginHeader+": "+env.login+"\r\n"+
|
||||||
|
"Content-Type: multipart/form-data; boundary=x\r\n"+
|
||||||
|
"Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+
|
||||||
|
"Connection: close\r\n\r\n")
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||||
|
assertOurForm(t, status, body)
|
||||||
|
assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом")
|
||||||
|
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("неизвестный путь под корнем приложения", func(t *testing.T) {
|
||||||
|
res := env.liveRequest(t, server, "/app/nosuchendpoint", nil)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||||
|
assertOurForm(t, res.StatusCode, res.Body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ограничитель частоты", func(t *testing.T) {
|
||||||
|
var last liveResponse
|
||||||
|
for range appRateMaxRequests + 5 {
|
||||||
|
last = env.liveRequest(t, server, "/app/me", nil)
|
||||||
|
if last.StatusCode == http.StatusTooManyRequests {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusTooManyRequests, last.StatusCode,
|
||||||
|
"ограничитель не сработал — проверке не на чем сработать")
|
||||||
|
assertOurForm(t, last.StatusCode, last.Body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа.
|
||||||
|
//
|
||||||
|
// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит
|
||||||
|
// соответствие объявленной длины телу, а проверке нужна ровно объявленная.
|
||||||
|
func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(context.Background(), "tcp", address)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, conn.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte(request))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return res.StatusCode, body
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/hook"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -17,9 +14,13 @@ import (
|
|||||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||||
|
//
|
||||||
|
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
|
||||||
|
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
|
||||||
|
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
|
||||||
|
// предъявителя без неё не бывает.
|
||||||
const (
|
const (
|
||||||
CodeUnauthorized = "unauthorized"
|
CodeUnauthorized = "unauthorized"
|
||||||
CodeForbidden = "forbidden"
|
|
||||||
CodeNotFound = "not_found"
|
CodeNotFound = "not_found"
|
||||||
CodeBadRequest = "bad_request"
|
CodeBadRequest = "bad_request"
|
||||||
CodeTooLarge = "too_large"
|
CodeTooLarge = "too_large"
|
||||||
@@ -32,7 +33,7 @@ const (
|
|||||||
//
|
//
|
||||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
|
||||||
// журнале владельца сервиса.
|
// журнале владельца сервиса.
|
||||||
//
|
//
|
||||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||||
@@ -46,8 +47,7 @@ type ErrorBody struct {
|
|||||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
// ошибка» на негодный файл.
|
||||||
// второе не говорило ему ничего.
|
|
||||||
//
|
//
|
||||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||||
@@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) {
|
|||||||
Message: "Текст этого вида для записи ещё не готов",
|
Message: "Текст этого вида для записи ещё не готов",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrCopyNotReady):
|
||||||
|
return http.StatusConflict, ErrorBody{
|
||||||
|
Code: CodeNotReady,
|
||||||
|
Message: "Этой копии записи ещё нет",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(err, contract.ErrTooManyRequests):
|
||||||
|
return http.StatusTooManyRequests, ErrorBody{
|
||||||
|
Code: CodeTooManyRequests,
|
||||||
|
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||||
|
}
|
||||||
|
|
||||||
case errors.Is(err, contract.ErrNotFound):
|
case errors.Is(err, contract.ErrNotFound):
|
||||||
message := "Адрес не найден"
|
message := "Адрес не найден"
|
||||||
var owned *messagedError
|
var owned *messagedError
|
||||||
@@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) {
|
|||||||
Code: CodeUnauthorized,
|
Code: CodeUnauthorized,
|
||||||
Message: "Сервис вас не узнал",
|
Message: "Сервис вас не узнал",
|
||||||
}
|
}
|
||||||
|
|
||||||
case errors.Is(err, contract.ErrOwnerRequired):
|
|
||||||
return http.StatusForbidden, ErrorBody{
|
|
||||||
Code: CodeForbidden,
|
|
||||||
Message: "У предъявителя нет учётной записи пользователя",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||||
@@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) {
|
|||||||
|
|
||||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||||
// уходит наружу с адресов приложения.
|
// уходит наружу с адресов приложения.
|
||||||
func fail(e *core.RequestEvent, err error) error {
|
//
|
||||||
|
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
|
||||||
|
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
|
||||||
|
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
|
||||||
|
// формы тела на адресах приложения не существует.
|
||||||
|
func fail(w http.ResponseWriter, err error) {
|
||||||
status, body := mapDomainError(err)
|
status, body := mapDomainError(err)
|
||||||
return e.JSON(status, body)
|
writeJSON(w, status, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к
|
||||||
//
|
// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом
|
||||||
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
// браузере наполняла бы журнал ничем.
|
||||||
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
w.WriteHeader(status)
|
||||||
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
//
|
|
||||||
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
|
||||||
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
|
||||||
// «внутренняя ошибка» вместо предела числом.
|
|
||||||
//
|
|
||||||
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
|
||||||
// него, включая предел тела и ограничитель частоты.
|
|
||||||
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
|
||||||
return &hook.Handler[*core.RequestEvent]{
|
|
||||||
Id: "transcriberOneErrorForm",
|
|
||||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
|
||||||
Func: func(e *core.RequestEvent) error {
|
|
||||||
err := e.Next()
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
|
||||||
// форма уже ушла в ответ, и сюда доходит только чужая.
|
|
||||||
var apiErr *router.ApiError
|
|
||||||
if !errors.As(err, &apiErr) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
status, translated := translateAPIError(apiErr)
|
|
||||||
return e.JSON(status, translated)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||||
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
// всех одна, а сказать человеку надо разное.
|
||||||
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
func errWithMessage(base error, message string) error {
|
||||||
switch apiErr.Status {
|
return &messagedError{base: base, message: message}
|
||||||
case http.StatusRequestEntityTooLarge:
|
|
||||||
limit := entity.MaxRecordSize
|
|
||||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
|
||||||
Code: CodeTooLarge,
|
|
||||||
Message: "Запись больше допустимого размера",
|
|
||||||
Limit: &limit,
|
|
||||||
}
|
|
||||||
case http.StatusTooManyRequests:
|
|
||||||
return http.StatusTooManyRequests, ErrorBody{
|
|
||||||
Code: CodeTooManyRequests,
|
|
||||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
|
||||||
}
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return http.StatusNotFound, ErrorBody{
|
|
||||||
Code: CodeNotFound,
|
|
||||||
Message: "Адрес не найден",
|
|
||||||
}
|
|
||||||
case http.StatusUnauthorized:
|
|
||||||
return mapDomainError(contract.ErrUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiErr.Status, ErrorBody{
|
|
||||||
Code: CodeInternal,
|
|
||||||
Message: "Внутренняя ошибка сервиса",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireUser — слой предъявления адресов приложения.
|
type messagedError struct {
|
||||||
//
|
base error
|
||||||
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
message string
|
||||||
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
}
|
||||||
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
|
||||||
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
|
||||||
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
|
||||||
// записи он стать не может.
|
|
||||||
//
|
|
||||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
|
||||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
|
||||||
// уложенный файл — чего сервис не умеет вовсе.
|
|
||||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
|
||||||
return &hook.Handler[*core.RequestEvent]{
|
|
||||||
Id: "transcriberRequireUser",
|
|
||||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
|
||||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
|
||||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
|
||||||
//
|
|
||||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
|
||||||
// неузнанному наступает **до** чтения тела.
|
|
||||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
|
||||||
Func: func(e *core.RequestEvent) error {
|
|
||||||
if e.Auth == nil {
|
|
||||||
return fail(e, contract.ErrUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
func (e *messagedError) Error() string { return e.message }
|
||||||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
func (e *messagedError) Unwrap() error { return e.base }
|
||||||
// предъявитель себя предъявил.
|
|
||||||
if e.Auth.Collection().Name != usersCollection {
|
|
||||||
return fail(e, contract.ErrOwnerRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Next()
|
// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей
|
||||||
},
|
// формой тела, а процесс живёт.
|
||||||
|
//
|
||||||
|
// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
|
||||||
|
// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
|
||||||
|
// процесс целиком, и это осознанно.
|
||||||
|
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
logger.Error("Handler panicked",
|
||||||
|
"error", recovered, "transport", "http")
|
||||||
|
fail(w, errors.New("handler panicked"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentTypes — тип содержимого по **известному** формату.
|
||||||
|
//
|
||||||
|
// Ключи здесь — значения той же единой точки, что и метка метрики: формат копии
|
||||||
|
// сперва приводится `metrics.FormatLabel`, и только приведённое ищется в этой
|
||||||
|
// карте. Справочник системы не спрашивается вовсе — он читается из файла хоста,
|
||||||
|
// которого в рабочем слое образа нет, и тип содержимого стал бы функцией того,
|
||||||
|
// где сервис собран.
|
||||||
|
//
|
||||||
|
// Перечень закрыт, и это главное его свойство. Расширение приходит из имени,
|
||||||
|
// данного отправителем, и потому может быть чем угодно: `запись.html` без
|
||||||
|
// приведения ушёл бы ответом `text/html`, который браузер показывает как
|
||||||
|
// страницу. Всё, чего в перечне нет, отдаётся `application/octet-stream` с
|
||||||
|
// расположением `attachment` — тип, который браузер не исполняет.
|
||||||
|
//
|
||||||
|
// Полноту перечня держит проверка: всякий формат, который сервис объявляет
|
||||||
|
// диалогу выбора файла, обязан иметь здесь тип содержимого.
|
||||||
|
var contentTypes = map[string]string{
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"avi": "video/x-msvideo",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"mkv": "video/x-matroska",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"oga": "audio/ogg",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"wma": "audio/x-ms-wma",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расположение ответа. Известному формату — показ на месте: проигрыватель в
|
||||||
|
// браузере иначе не откроет запись. Всему прочему — сохранение: содержимым и
|
||||||
|
// расширением распоряжается отправитель, а `attachment` браузер не исполняет.
|
||||||
|
const (
|
||||||
|
dispositionInline = "inline"
|
||||||
|
dispositionAttachment = "attachment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unknownContentType — тип содержимого всего, чего нет в закрытом перечне.
|
||||||
|
const unknownContentType = "application/octet-stream"
|
||||||
|
|
||||||
|
// GetRecordFile отдаёт копию записи её владельцу.
|
||||||
|
//
|
||||||
|
// **Порядок проверок один: владение записью судится до разбора значения копии.**
|
||||||
|
// Неизвестное либо незаданное значение копии у чужой и у несуществующей записи
|
||||||
|
// даёт тот же ответ, что и неизвестный идентификатор. Неотличимость чужой записи
|
||||||
|
// от несуществующей главнее формы ответа на негодный ввод: разбор параметра,
|
||||||
|
// выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только
|
||||||
|
// случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы
|
||||||
|
// список заведённых записей одним негодным параметром.
|
||||||
|
func (h *AppHandler) GetRecordFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
account, _ := AccountOf(r)
|
||||||
|
|
||||||
|
record, err := h.readOwn(r, account.ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID, known := copyOf(record, r.URL.Query().Get(CopyParam))
|
||||||
|
if !known {
|
||||||
|
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестная копия записи"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Копии, которой у записи ещё нет, отвечает отказ состояния: пустой ответ
|
||||||
|
// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
|
||||||
|
// неизвестную запись — человек увидел бы его на своей записи, загруженной
|
||||||
|
// минуту назад.
|
||||||
|
if fileID == nil {
|
||||||
|
fail(w, contract.ErrCopyNotReady)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := h.fileRepo.GetByID(*fileID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read the file row of a record", "error", err, "record_id", record.Id)
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := h.fileRepo.Open(*fileID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to open the file of a record", "error", err, "record_id", record.Id)
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = content.Close() }()
|
||||||
|
|
||||||
|
h.serveCopy(w, r, record, file, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyOf выбирает копию по значению параметра. Второе значение ложно у копии,
|
||||||
|
// которой сервис не знает, и у незаданной: умолчание сделало бы ответ функцией
|
||||||
|
// того, что успел записать конвейер, а не состояния записи.
|
||||||
|
func copyOf(record *entity.AudioRecord, name string) (*string, bool) {
|
||||||
|
switch name {
|
||||||
|
case CopyOriginal:
|
||||||
|
return record.OriginalFileID, true
|
||||||
|
case CopyNormalized:
|
||||||
|
return record.NormalizedFileID, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveCopy отдаёт содержимое целиком либо запрошенным куском.
|
||||||
|
//
|
||||||
|
// Выдача по частям обязательна: запись расчётного потолка — шесть часов, и
|
||||||
|
// проигрыватель в браузере перематывает её запросом диапазона, а не повторной
|
||||||
|
// загрузкой целиком.
|
||||||
|
//
|
||||||
|
// **Негодный диапазон приводится к обычному отказу сервиса** — телом той же
|
||||||
|
// формы и кодом из закрытого перечня, — а не отвечает `416` телом библиотеки.
|
||||||
|
// Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый
|
||||||
|
// (начало за концом файла) и множественный (в запросе назван больше чем один
|
||||||
|
// диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это
|
||||||
|
// отдельный тип содержимого со своими границами, а просит его один только
|
||||||
|
// самодельный запрос.
|
||||||
|
func (h *AppHandler) serveCopy(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
record *entity.AudioRecord,
|
||||||
|
file *entity.File,
|
||||||
|
content io.ReadSeeker,
|
||||||
|
) {
|
||||||
|
contentType, disposition := presentationOf(file.Format)
|
||||||
|
|
||||||
|
header := w.Header()
|
||||||
|
header.Set("Content-Type", contentType)
|
||||||
|
header.Set("Accept-Ranges", "bytes")
|
||||||
|
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при
|
||||||
|
// сохранении, строится из имени, данного отправителем, и лежит оно колонкой
|
||||||
|
// записи.
|
||||||
|
header.Set("Content-Disposition", dispositionOf(record, disposition))
|
||||||
|
|
||||||
|
raw := r.Header.Get("Range")
|
||||||
|
if raw == "" {
|
||||||
|
header.Set("Content-Length", strconv.FormatInt(file.Size, 10))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
h.copyBody(w, r, content, file.Size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start, length, ok := parseSingleRange(raw, file.Size)
|
||||||
|
if !ok {
|
||||||
|
fail(w, errWithMessage(contract.ErrBadRequest, "Запрошенный диапазон записи не читается"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := content.Seek(start, io.SeekStart); err != nil {
|
||||||
|
h.logger.Error("Failed to seek the file of a record", "error", err, "record_id", record.Id)
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, file.Size))
|
||||||
|
header.Set("Content-Length", strconv.FormatInt(length, 10))
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
h.copyBody(w, r, content, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyBody переливает содержимое в ответ. Запрос `HEAD` тела не получает: у него
|
||||||
|
// те же заголовки и пустое тело.
|
||||||
|
//
|
||||||
|
// Отказ переливания идёт **отладочной** строкой: он значит оборванное
|
||||||
|
// соединение — человек закрыл вкладку или перемотал запись, — и владельцу
|
||||||
|
// сервиса разбирать здесь нечего. Проглотить его молча всё же нельзя: тогда
|
||||||
|
// оборванная отдача не отличалась бы от полной ничем.
|
||||||
|
func (h *AppHandler) copyBody(w http.ResponseWriter, r *http.Request, content io.Reader, length int64) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.CopyN(w, content, length); err != nil {
|
||||||
|
h.logger.Debug("Failed to send the file of a record", "error", err, "transport", "http")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presentationOf называет тип содержимого копии и её расположение.
|
||||||
|
//
|
||||||
|
// Оба значения выводятся из одного приведения, и порознь их выводить нельзя:
|
||||||
|
// известный формат, показанный на месте, и незнакомый, отданный на сохранение, —
|
||||||
|
// это одно решение, а два независимых дали бы `text/html` с `inline` у первого
|
||||||
|
// же расширения, которого сервис не знает.
|
||||||
|
func presentationOf(format string) (contentType, disposition string) {
|
||||||
|
if known, ok := contentTypes[metrics.FormatLabel(format)]; ok {
|
||||||
|
return known, dispositionInline
|
||||||
|
}
|
||||||
|
return unknownContentType, dispositionAttachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispositionOf строит расположение ответа вместе с именем, предлагаемым
|
||||||
|
// браузеру при сохранении.
|
||||||
|
//
|
||||||
|
// Имя берётся у записи — то, что дал отправитель, — и кодируется по правилам
|
||||||
|
// заголовка: оно приходит извне и содержимым своим сервису не подконтрольно.
|
||||||
|
// Записи без имени получают одно расположение, без имени файла.
|
||||||
|
func dispositionOf(record *entity.AudioRecord, disposition string) string {
|
||||||
|
if record.OriginalFilename == nil || *record.OriginalFilename == "" {
|
||||||
|
return disposition
|
||||||
|
}
|
||||||
|
return disposition + "; filename*=UTF-8''" + url.PathEscape(*record.OriginalFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSingleRange разбирает заголовок диапазона.
|
||||||
|
//
|
||||||
|
// Второе значение ложно у всего, что сервис не отдаёт: у нечитаемого заголовка,
|
||||||
|
// у неудовлетворимого диапазона и у запроса, называющего больше одного
|
||||||
|
// диапазона.
|
||||||
|
func parseSingleRange(raw string, size int64) (start, length int64, ok bool) {
|
||||||
|
const prefix = "bytes="
|
||||||
|
|
||||||
|
spec, found := strings.CutPrefix(strings.TrimSpace(raw), prefix)
|
||||||
|
if !found || strings.Contains(spec, ",") {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
first, last, found := strings.Cut(strings.TrimSpace(spec), "-")
|
||||||
|
if !found {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
first, last = strings.TrimSpace(first), strings.TrimSpace(last)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case first == "":
|
||||||
|
// Хвост записи: `bytes=-N` просит последние N байтов.
|
||||||
|
suffix, err := strconv.ParseInt(last, 10, 64)
|
||||||
|
if err != nil || suffix <= 0 || size == 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if suffix > size {
|
||||||
|
suffix = size
|
||||||
|
}
|
||||||
|
return size - suffix, suffix, true
|
||||||
|
|
||||||
|
case last == "":
|
||||||
|
start, err := strconv.ParseInt(first, 10, 64)
|
||||||
|
if err != nil || start < 0 || start >= size {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return start, size - start, true
|
||||||
|
|
||||||
|
default:
|
||||||
|
start, err := strconv.ParseInt(first, 10, 64)
|
||||||
|
if err != nil || start < 0 || start >= size {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
end, err := strconv.ParseInt(last, 10, 64)
|
||||||
|
if err != nil || end < start {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if end >= size {
|
||||||
|
end = size - 1
|
||||||
|
}
|
||||||
|
return start, end - start + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,121 +1,93 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/hook"
|
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||||
//
|
//
|
||||||
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
|
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
|
||||||
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
|
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
|
||||||
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
|
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
|
||||||
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
|
// где их можно написать неверно, выгоды не даёт.
|
||||||
// написать неверно, выгоды не даёт.
|
|
||||||
const (
|
const (
|
||||||
LoginHeader = "Remote-User"
|
LoginHeader = "Remote-User"
|
||||||
NameHeader = "Remote-Name"
|
NameHeader = "Remote-Name"
|
||||||
EmailHeader = "Remote-Email"
|
EmailHeader = "Remote-Email"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
|
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||||||
//
|
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||||||
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
|
// служить не может — кто пришёл, решает адрес самого соединения.
|
||||||
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
|
const ForwardedForHeader = "X-Forwarded-For"
|
||||||
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
|
|
||||||
const FileTokenPath = StorageRoot + "/files/token"
|
// accountKey — ключ, под которым узнанная учётная запись живёт в контексте
|
||||||
|
// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал
|
||||||
|
// бы значение.
|
||||||
|
type accountKey struct{}
|
||||||
|
|
||||||
|
// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение
|
||||||
|
// ложно у неузнанного.
|
||||||
|
func AccountOf(r *http.Request) (*contract.UserAccount, bool) {
|
||||||
|
account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount)
|
||||||
|
return account, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAccount кладёт узнанную учётную запись в контекст запроса.
|
||||||
|
func withAccount(r *http.Request, account *contract.UserAccount) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), accountKey{}, account))
|
||||||
|
}
|
||||||
|
|
||||||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||||||
//
|
//
|
||||||
// # Область
|
// # Область
|
||||||
//
|
//
|
||||||
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
|
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
|
||||||
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
|
// выводится из объявленного адресного пространства сервиса, а не перечисляется
|
||||||
// узнаёт он **только на объявленной области**: корень приложения плюс этот
|
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
|
||||||
// адрес. Область выводится из перечня адресного пространства, а не пишется
|
|
||||||
// вторым списком.
|
|
||||||
//
|
//
|
||||||
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
|
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
|
||||||
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
|
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
|
||||||
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
|
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
|
||||||
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
|
// новым именем — записи в неё.
|
||||||
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
|
|
||||||
// два барьера здесь именно потому, что прежний был один и держался на
|
|
||||||
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
|
|
||||||
//
|
|
||||||
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
|
|
||||||
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
|
|
||||||
// базе, а первый такой запрос с новым именем — записи в неё.
|
|
||||||
//
|
|
||||||
// # Кто побеждает
|
|
||||||
//
|
|
||||||
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
|
|
||||||
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
|
|
||||||
// учётной записью пользователя отобрала бы у него панель посреди работы.
|
|
||||||
// Протухший и негодный токен предъявленными не считаются: библиотека их не
|
|
||||||
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
|
|
||||||
//
|
//
|
||||||
// # Чего слой не делает
|
// # Чего слой не делает
|
||||||
//
|
//
|
||||||
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
|
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
|
||||||
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
|
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
|
||||||
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
|
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
|
||||||
// учётной записи на адресах приложения.
|
// где легла база.
|
||||||
//
|
|
||||||
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
|
|
||||||
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
|
|
||||||
// легла база.
|
|
||||||
func TrustedHeaderIdentity(
|
func TrustedHeaderIdentity(
|
||||||
app core.App,
|
users contract.UserRepository,
|
||||||
mounts []Mount,
|
|
||||||
trusted []netip.Prefix,
|
trusted []netip.Prefix,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *hook.Handler[*core.RequestEvent] {
|
) func(http.Handler) http.Handler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &hook.Handler[*core.RequestEvent]{
|
return func(next http.Handler) http.Handler {
|
||||||
Id: "transcriberTrustedHeaderIdentity",
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
|
|
||||||
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
|
|
||||||
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
|
|
||||||
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
|
|
||||||
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
|
|
||||||
// убираются ничем.
|
|
||||||
//
|
|
||||||
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
|
|
||||||
// узнавание (−999) → требование учётной записи (−998) → предел тела
|
|
||||||
// (−990). Требование стоит перед пределом тела намеренно: отказ
|
|
||||||
// неузнанному обязан наступать до чтения тела.
|
|
||||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
|
|
||||||
Func: func(e *core.RequestEvent) error {
|
|
||||||
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||||
// бы вход анониму.
|
// бы вход анониму.
|
||||||
values := e.Request.Header.Values(LoginHeader)
|
values := r.Header.Values(LoginHeader)
|
||||||
if len(values) != 1 {
|
if len(values) != 1 {
|
||||||
if len(values) > 1 {
|
if len(values) > 1 {
|
||||||
// Уровень предупреждающий: два значения означают прокси,
|
// Уровень предупреждающий: два значения означают прокси,
|
||||||
// который заголовок **добавляет** вместо замены, — то есть
|
// который заголовок **добавляет** вместо замены, — то есть
|
||||||
// ровно ту поломку контура, которую модель угроз называет
|
// ровно ту поломку контура, которую модель угроз называет
|
||||||
// главной. Отладочным уровнем она в бою не видна вовсе:
|
// главной. Отладочным уровнем она в бою не видна вовсе.
|
||||||
// боевой уровень журнала информационный.
|
|
||||||
logger.Warn("Request carries more than one login header",
|
logger.Warn("Request carries more than one login header",
|
||||||
"http.peer_addr", e.Request.RemoteAddr,
|
"http.peer_addr", r.RemoteAddr,
|
||||||
"capability", "access", "transport", "http")
|
"capability", "access", "transport", "http")
|
||||||
} else {
|
} else {
|
||||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||||
@@ -123,13 +95,14 @@ func TrustedHeaderIdentity(
|
|||||||
// штатный — так выглядит и человек, которому провайдер
|
// штатный — так выглядит и человек, которому провайдер
|
||||||
// отказал.
|
// отказал.
|
||||||
logger.Debug("Request carries no login header",
|
logger.Debug("Request carries no login header",
|
||||||
"http.peer_addr", e.Request.RemoteAddr,
|
"http.peer_addr", r.RemoteAddr,
|
||||||
"capability", "access", "transport", "http")
|
"capability", "access", "transport", "http")
|
||||||
}
|
}
|
||||||
return e.Next()
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
peer, ok := peerAddress(e.Request.RemoteAddr)
|
peer, ok := peerAddress(r.RemoteAddr)
|
||||||
if !ok || !isTrusted(trusted, peer) {
|
if !ok || !isTrusted(trusted, peer) {
|
||||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||||
@@ -143,31 +116,34 @@ func TrustedHeaderIdentity(
|
|||||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||||
// заголовка не ставит).
|
// заголовка не ставит).
|
||||||
logger.Warn("Login header came from an untrusted peer",
|
logger.Warn("Login header came from an untrusted peer",
|
||||||
"http.peer_addr", e.Request.RemoteAddr,
|
"http.peer_addr", r.RemoteAddr,
|
||||||
"capability", "access", "transport", "http")
|
"capability", "access", "transport", "http")
|
||||||
return e.Next()
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
|
account, created, err := users.EnsureUser(contract.Identity{
|
||||||
Login: values[0],
|
Login: values[0],
|
||||||
Name: e.Request.Header.Get(NameHeader),
|
Name: r.Header.Get(NameHeader),
|
||||||
Email: e.Request.Header.Get(EmailHeader),
|
Email: r.Header.Get(EmailHeader),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
|
if errors.Is(err, contract.ErrLoginNotAcceptable) {
|
||||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||||
// неузнанным.
|
// неузнанным.
|
||||||
logger.Debug("Login header value is not acceptable",
|
logger.Debug("Login header value is not acceptable",
|
||||||
"http.peer_addr", e.Request.RemoteAddr,
|
"http.peer_addr", r.RemoteAddr,
|
||||||
"capability", "access", "transport", "http")
|
"capability", "access", "transport", "http")
|
||||||
return e.Next()
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Error("Failed to resolve account by login header",
|
logger.Error("Failed to resolve account by login header",
|
||||||
"error", err, "capability", "access", "transport", "http")
|
"error", err, "capability", "access", "transport", "http")
|
||||||
return fail(e, err)
|
fail(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
@@ -181,36 +157,35 @@ func TrustedHeaderIdentity(
|
|||||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||||
// записи — оба выданы не спрашивающим.
|
// записи — оба выданы не спрашивающим.
|
||||||
logger.Info("Account created from login header",
|
logger.Info("Account created from login header",
|
||||||
"http.peer_addr", e.Request.RemoteAddr,
|
"http.peer_addr", r.RemoteAddr,
|
||||||
"account_id", record.Id,
|
"account_id", account.ID,
|
||||||
"capability", "access", "transport", "http")
|
"capability", "access", "transport", "http")
|
||||||
}
|
}
|
||||||
|
|
||||||
e.Auth = record
|
next.ServeHTTP(w, withAccount(r, account))
|
||||||
|
})
|
||||||
return e.Next()
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
|
// RequireUser — слой предъявления адресов приложения.
|
||||||
//
|
//
|
||||||
// Область — корень приложения из перечня адресного пространства плюс адрес
|
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||||
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
|
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||||
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
|
// уложенный файл — чего сервис не умеет вовсе.
|
||||||
// он разошёлся бы с ней молча.
|
//
|
||||||
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
|
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
|
||||||
if requestPath == FileTokenPath {
|
// учётную запись само, и предъявителя без неё не бывает.
|
||||||
return true
|
func RequireUser() func(http.Handler) http.Handler {
|
||||||
}
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := AccountOf(r); !ok {
|
||||||
|
fail(w, contract.ErrUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, mount := range mounts {
|
next.ServeHTTP(w, r)
|
||||||
if mount.Path == AppRoot {
|
})
|
||||||
return mount.Covers(requestPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
||||||
|
const webappRoute = "<приложение>"
|
||||||
|
|
||||||
|
// journalNote — то, что обработчик оставляет слою журнала о своём запросе.
|
||||||
|
// Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже.
|
||||||
|
type journalNote struct {
|
||||||
|
outcome string
|
||||||
|
}
|
||||||
|
|
||||||
|
type journalKey struct{}
|
||||||
|
|
||||||
|
// noteWebappOutcome оставляет исход раздачи слою журнала.
|
||||||
|
func noteWebappOutcome(r *http.Request, outcome string) {
|
||||||
|
if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok {
|
||||||
|
note.outcome = outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal пишет строку о каждом входящем запросе.
|
||||||
|
//
|
||||||
|
// **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него
|
||||||
|
// маршрут из закрытого перечня и длина: по ним видно, что происходит, а
|
||||||
|
// дословная запись сделала бы журнал местом, куда аноним пишет свой текст
|
||||||
|
// произвольной длины. Корень приложения от этого правила не изъят: путь под ним
|
||||||
|
// выбирает тот же спрашивающий.
|
||||||
|
//
|
||||||
|
// Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком
|
||||||
|
// вместе с адресом отправителя, ушёл вместе с ней.
|
||||||
|
func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := clock.Start()
|
||||||
|
|
||||||
|
note := &journalNote{}
|
||||||
|
ctx := context.WithValue(r.Context(), journalKey{}, note)
|
||||||
|
|
||||||
|
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(recorder, r.WithContext(ctx))
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if IsObservationAddress(mounts, r.URL.Path) {
|
||||||
|
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := []any{
|
||||||
|
"http.method", r.Method,
|
||||||
|
"http.route", JournalRoute(r, mounts),
|
||||||
|
"http.status_code", recorder.status,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
// Длина идёт на всякую строку, а не только на раздачу
|
||||||
|
// приложения: обобщённое значение маршрута теперь достаётся и
|
||||||
|
// путям под корнем приложения, и без длины по строке не видно,
|
||||||
|
// спросили короткий адрес или мегабайт текста.
|
||||||
|
"http.path_length", len(r.URL.Path),
|
||||||
|
"transport", "http",
|
||||||
|
}
|
||||||
|
|
||||||
|
if note.outcome != "" {
|
||||||
|
attrs = append(attrs, "webapp.outcome", note.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log(ctx, level, "Incoming request", attrs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalRoute готовит путь запроса к записи в журнал.
|
||||||
|
//
|
||||||
|
// **Дословно в журнал идёт только то, что принадлежит закрытому перечню**:
|
||||||
|
// точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь
|
||||||
|
// вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним
|
||||||
|
// образцом, — обозначается одним значением, а длина уходит отдельным полем.
|
||||||
|
//
|
||||||
|
// Прежнее правило судило по принадлежности пути сервису и оставляло зазор
|
||||||
|
// шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису,
|
||||||
|
// отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством
|
||||||
|
// значений под корнем распоряжается спрашивающий ровно так же, как и вне его, —
|
||||||
|
// значит и правило одно на обе половины.
|
||||||
|
//
|
||||||
|
// Идентификатор записи из строки при этом не пропадает: его пишет обработчик
|
||||||
|
// полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили.
|
||||||
|
//
|
||||||
|
// Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда
|
||||||
|
// не попадает по построению: адрес копии записи назван идентификатором самой
|
||||||
|
// записи, а имя файла на диске в путь не входит вовсе.
|
||||||
|
func JournalRoute(r *http.Request, mounts []Mount) string {
|
||||||
|
if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok {
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern, ok := appRoutePatternOf(r); ok {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
return webappRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
// appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу
|
||||||
|
// приложения отвечает этот запрос.
|
||||||
|
//
|
||||||
|
// Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная
|
||||||
|
// библиотека, и второй разбор рядом с ней разошёлся бы с настоящей
|
||||||
|
// маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются
|
||||||
|
// обработчики.
|
||||||
|
var appRouteIndex = newAppRouteIndex()
|
||||||
|
|
||||||
|
func newAppRouteIndex() *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
for _, pattern := range AppRoutePatterns {
|
||||||
|
mux.Handle(pattern, http.NotFoundHandler())
|
||||||
|
}
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с
|
||||||
|
// ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом,
|
||||||
|
// собранным из самого пути, и без сверки такой ответ уехал бы в журнал
|
||||||
|
// дословно — то есть ровно тем, чего правило не допускает.
|
||||||
|
var knownAppRoutes = knownAppRouteSet()
|
||||||
|
|
||||||
|
func knownAppRouteSet() map[string]struct{} {
|
||||||
|
out := make(map[string]struct{}, len(AppRoutePatterns))
|
||||||
|
for _, pattern := range AppRoutePatterns {
|
||||||
|
out[pattern] = struct{}{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// appRoutePatternOf называет образец адреса приложения. Второе значение ложно у
|
||||||
|
// всего, что ни одному образцу не отвечает.
|
||||||
|
func appRoutePatternOf(r *http.Request) (string, bool) {
|
||||||
|
_, pattern := appRouteIndex.Handler(r)
|
||||||
|
if _, ok := knownAppRoutes[pattern]; !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return pattern, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusRecorder запоминает код ответа: журнал пишется после обработчика, а
|
||||||
|
// готовый код читать больше неоткуда.
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
written bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusRecorder) WriteHeader(status int) {
|
||||||
|
if !w.written {
|
||||||
|
w.status = status
|
||||||
|
w.written = true
|
||||||
|
}
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusRecorder) Write(p []byte) (int, error) {
|
||||||
|
w.written = true
|
||||||
|
return w.ResponseWriter.Write(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalMounts — перечень адресного пространства для проверок журнала.
|
||||||
|
// Обработчиков он здесь не вешает: журналу нужны только границы, а не то, что
|
||||||
|
// стоит за ними.
|
||||||
|
func journalMounts() []Mount {
|
||||||
|
return []Mount{
|
||||||
|
{Path: AppRoot},
|
||||||
|
{Path: HealthPath, Exact: true},
|
||||||
|
{Path: MetricsPath, Exact: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalRouteOf — маршрут строки журнала для названного пути.
|
||||||
|
func journalRouteOf(path string) string {
|
||||||
|
return JournalRoute(httptest.NewRequest(http.MethodGet, path, http.NoBody), journalMounts())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь, которым распоряжается спрашивающий, в журнал дословно не идёт — и
|
||||||
|
// принадлежность сервису тут ничего не меняет: путь под корнем приложения
|
||||||
|
// выбирает тот же аноним, что и путь вне корней.
|
||||||
|
func TestJournalRouteHidesRequestedPath(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"/",
|
||||||
|
"/records/abc123def456ghi",
|
||||||
|
"/" + strings.Repeat("a", 1024),
|
||||||
|
"/api/files/files/rec0000000000000/0f7b8dd3-d1cc-424c.mp3",
|
||||||
|
"/_/",
|
||||||
|
// Под корнем приложения — то же самое: образца с таким хвостом нет.
|
||||||
|
"/app",
|
||||||
|
"/app/",
|
||||||
|
"/app/" + strings.Repeat("b", 1024),
|
||||||
|
"/app/audiorecords/abc123def456ghi/file/" + strings.Repeat("c", 512),
|
||||||
|
"/app/audiorecords/abc/def/ghi",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range cases {
|
||||||
|
assert.Equal(t, webappRoute, journalRouteOf(path), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дословно пишется закрытый перечень: точные адреса наблюдения и образцы
|
||||||
|
// адресов приложения. Идентификатор записи в образец не входит — он приходит
|
||||||
|
// строкой обработчика полем `record_id`.
|
||||||
|
func TestJournalRouteKeepsClosedList(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"/app/audiorecords": AppRouteRecords,
|
||||||
|
"/app/audiorecords/abc123def456ghi": AppRouteRecord,
|
||||||
|
"/app/audiorecords/abc123def456ghi/text": AppRouteRecordText,
|
||||||
|
"/app/audiorecords/abc123def456ghi/file": AppRouteRecordFile,
|
||||||
|
"/app/me": AppRouteMe,
|
||||||
|
"/app/config": AppRouteConfig,
|
||||||
|
HealthPath: HealthPath,
|
||||||
|
MetricsPath: MetricsPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, want := range cases {
|
||||||
|
assert.Equal(t, want, journalRouteOf(path), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение маршрута берётся из перечня образцов и ничего сверх него не
|
||||||
|
// возвращает: путь, приведённый маршрутизатором к другому виду, дословно уехать
|
||||||
|
// не может.
|
||||||
|
func TestJournalRouteAnswersOnlyFromClosedList(t *testing.T) {
|
||||||
|
allowed := map[string]bool{webappRoute: true, HealthPath: true, MetricsPath: true}
|
||||||
|
for _, pattern := range AppRoutePatterns {
|
||||||
|
allowed[pattern] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []string{
|
||||||
|
"/app/me/",
|
||||||
|
"/app/audiorecords/../me",
|
||||||
|
"/app//me",
|
||||||
|
"/app/audiorecords/{id}",
|
||||||
|
"/app/me/" + strings.Repeat("d", 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range cases {
|
||||||
|
route := journalRouteOf(path)
|
||||||
|
assert.True(t, allowed[route], "маршрут %q не принадлежит закрытому перечню (путь %q)", route, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя, под которым копия легла в каталог данных, в журнал не идёт: строка
|
||||||
|
// журнала иначе стала бы бессрочным ключом к чужой записи. Сегодня оно не
|
||||||
|
// попадает туда по построению — адрес копии назван идентификатором самой записи,
|
||||||
|
// — и проверка сторожит именно это.
|
||||||
|
func TestJournalCarriesNoStoredFileName(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
recordID := intakeItemOf(t, created).ID
|
||||||
|
require.Equal(t, http.StatusOK,
|
||||||
|
env.getOwn("/app/audiorecords/"+recordID+"/file?"+CopyParam+"="+CopyOriginal).Code)
|
||||||
|
|
||||||
|
names := storedFileNames(t, env)
|
||||||
|
require.Len(t, names, 1)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
assert.NotContains(t, journal, names[0], "имя файла в каталоге данных уехало в журнал")
|
||||||
|
assert.Contains(t, journal, recordID, "идентификатор записи остаётся: по нему прослеживается путь")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь, отданный приложению, журнал заменяет исходом и длиной: строка о нём не
|
||||||
|
// растёт вместе с длиной пути.
|
||||||
|
func TestJournalWebappOutcomeInsteadOfPath(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
long := "/" + strings.Repeat("щ", 500)
|
||||||
|
require.Equal(t, http.StatusOK, env.get(long).Code)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
assert.NotContains(t, journal, strings.Repeat("щ", 500), "путь уехал в журнал дословно")
|
||||||
|
assert.Contains(t, journal, "webapp.outcome="+OutcomeMarkup)
|
||||||
|
assert.Contains(t, journal, "http.path_length=")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неузнанный запрос под корнем приложения журнал тоже не пишет дословно, а
|
||||||
|
// строка о нём не растёт вместе с длиной пути. Прежде путь под корнем считался
|
||||||
|
// принадлежащим сервису и уезжал в строку целиком — аноним писал в журнал
|
||||||
|
// владельца свой текст произвольной длины.
|
||||||
|
func TestJournalHidesAnonymousPathUnderAppRoot(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
const filler = 4096
|
||||||
|
long := "/app/" + strings.Repeat("щ", filler)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, env.get(long).Code)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
assert.NotContains(t, journal, strings.Repeat("щ", filler), "путь уехал в журнал дословно")
|
||||||
|
assert.Contains(t, journal, "http.route="+webappRoute)
|
||||||
|
assert.Contains(t, journal, "http.path_length=")
|
||||||
|
assert.Less(t, len(journal), filler,
|
||||||
|
"строка журнала растёт вместе с длиной запрошенного пути")
|
||||||
|
}
|
||||||
@@ -9,12 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +165,7 @@ func TestList_MalformedCursorIsRejected(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "декодируется, но идентификатора нет",
|
name: "декодируется, но идентификатора нет",
|
||||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")),
|
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "пара пуста целиком",
|
name: "пара пуста целиком",
|
||||||
@@ -202,25 +199,11 @@ func TestTopicsResolveToNames(t *testing.T) {
|
|||||||
|
|
||||||
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
|
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
|
||||||
// задача языковой модели, которой ещё нет.
|
// задача языковой модели, которой ещё нет.
|
||||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
topicID := newTopic(t, env, env.account.ID, "семейный архив")
|
||||||
require.NoError(t, err)
|
attachTopic(t, env, all.Items[0].ID, topicID)
|
||||||
topic := core.NewRecord(topics)
|
|
||||||
topic.Set("owner", env.account.Id)
|
|
||||||
topic.Set("name", "семейный архив")
|
|
||||||
require.NoError(t, env.app.Save(topic))
|
|
||||||
|
|
||||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
|
||||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.TopicIDs = []string{topic.Id}
|
|
||||||
require.NoError(t, repo.Save(record, ""))
|
|
||||||
|
|
||||||
// Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это
|
|
||||||
// сделает задача языковой модели, — прямым сохранением записи коллекции.
|
|
||||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
raw.Set("topics", []string{topic.Id})
|
|
||||||
require.NoError(t, env.app.Save(raw))
|
|
||||||
|
|
||||||
page := pageOf(t, env, "")
|
page := pageOf(t, env, "")
|
||||||
require.Len(t, page.Items, 1)
|
require.Len(t, page.Items, 1)
|
||||||
@@ -247,14 +230,14 @@ func TestList_ThreeStatesEachRecordOnce(t *testing.T) {
|
|||||||
all := pageOf(t, env, "")
|
all := pageOf(t, env, "")
|
||||||
require.Len(t, all.Items, 3)
|
require.Len(t, all.Items, 3)
|
||||||
|
|
||||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
repo := env.handler.recordRepo
|
||||||
|
|
||||||
halted, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
halted, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
halted.Halt(entity.HaltReasonStuck, "застряла")
|
halted.Halt(entity.HaltReasonStuck, "застряла")
|
||||||
require.NoError(t, repo.Save(halted, ""))
|
require.NoError(t, repo.Save(halted, ""))
|
||||||
|
|
||||||
done, err := repo.GetByID(all.Items[1].ID, env.account.Id)
|
done, err := repo.GetByID(all.Items[1].ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
done.MoveToState(entity.StateDone)
|
done.MoveToState(entity.StateDone)
|
||||||
require.NoError(t, repo.Save(done, ""))
|
require.NoError(t, repo.Save(done, ""))
|
||||||
@@ -295,11 +278,11 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
|||||||
|
|
||||||
acceptRecords(t, env, 2)
|
acceptRecords(t, env, 2)
|
||||||
|
|
||||||
_, stranger := newSecondAccount(t, env.app)
|
newSecondAccount(t, env)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||||
asUser(req, stranger)
|
asUser(req, "stranger")
|
||||||
env.mux.ServeHTTP(w, req)
|
env.mux.ServeHTTP(w, req)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
@@ -319,12 +302,12 @@ func TestList_DoesNotReadTranscript(t *testing.T) {
|
|||||||
all := pageOf(t, env, "")
|
all := pageOf(t, env, "")
|
||||||
require.Len(t, all.Items, 1)
|
require.Len(t, all.Items, 1)
|
||||||
|
|
||||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
repo := env.handler.recordRepo
|
||||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
|
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.TranscriptTextID = &transcript.Id
|
record.TranscriptTextID = &transcript.Id
|
||||||
@@ -413,19 +396,10 @@ func TestForeignTopicDoesNotResolve(t *testing.T) {
|
|||||||
all := pageOf(t, env, "")
|
all := pageOf(t, env, "")
|
||||||
require.Len(t, all.Items, 1)
|
require.Len(t, all.Items, 1)
|
||||||
|
|
||||||
stranger, _ := newSecondAccount(t, env.app)
|
stranger := newSecondAccount(t, env)
|
||||||
|
|
||||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
||||||
require.NoError(t, err)
|
attachTopic(t, env, all.Items[0].ID, foreign)
|
||||||
foreign := core.NewRecord(topics)
|
|
||||||
foreign.Set("owner", stranger.Id)
|
|
||||||
foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
|
||||||
require.NoError(t, env.app.Save(foreign))
|
|
||||||
|
|
||||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
raw.Set("topics", []string{foreign.Id})
|
|
||||||
require.NoError(t, env.app.Save(raw))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отдельные адреса наблюдения. Корней у сервиса остался один — корень
|
||||||
|
// приложения; `/api` и `/_` ушли вместе со встроенным хранилищем и его панелью,
|
||||||
|
// и адресов под этими именами не существует.
|
||||||
|
//
|
||||||
|
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||||
|
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||||
|
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||||
|
const (
|
||||||
|
HealthPath = "/health"
|
||||||
|
MetricsPath = "/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||||
|
//
|
||||||
|
// Перечень этих частей — **единственное** описание того, что сервису
|
||||||
|
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||||
|
// заведённый мимо перечня, не получит обработчика вовсе. Из него же выводятся
|
||||||
|
// правило неизвестного пути, уровень журнала и область действия узнавания.
|
||||||
|
type Mount struct {
|
||||||
|
// Path — корень либо точный адрес.
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||||
|
// только сам себя, а `/app` — всё, что под ним.
|
||||||
|
Exact bool
|
||||||
|
|
||||||
|
// Bind вешает обработчики этой части.
|
||||||
|
Bind func(mux *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||||
|
//
|
||||||
|
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||||
|
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||||
|
// `/apple`; по одному префиксу с косой чертой голый `/app` не достался бы никому
|
||||||
|
// и уехал бы разметкой приложения.
|
||||||
|
func (m Mount) Covers(requestPath string) bool {
|
||||||
|
if m.Exact {
|
||||||
|
return requestPath == m.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||||
|
//
|
||||||
|
// Обработчик приложения приходит уже одетым в свои слои — ограничитель частоты,
|
||||||
|
// узнавание, требование учётной записи: область их действия и есть корень
|
||||||
|
// приложения, и берётся она отсюда, а не перечисляется вторым списком.
|
||||||
|
func ServiceMounts(app http.Handler, metricsHandler http.Handler) []Mount {
|
||||||
|
return []Mount{
|
||||||
|
{Path: AppRoot, Bind: bindApp(app)},
|
||||||
|
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||||
|
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||||
|
func RegisterServiceRoutes(mux *http.ServeMux, mounts []Mount) {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Bind != nil {
|
||||||
|
mount.Bind(mux)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExactAddressOf называет точный адрес сервиса, которому отвечает путь. Второе
|
||||||
|
// значение ложно у всего прочего — у пути под корнем и у пути вне корней.
|
||||||
|
//
|
||||||
|
// Точные адреса — закрытый перечень, и только они пишутся в журнал дословно:
|
||||||
|
// значением такого пути распоряжается не спрашивающий, а сам перечень.
|
||||||
|
func ExactAddressOf(mounts []Mount, requestPath string) (string, bool) {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Exact && mount.Covers(requestPath) {
|
||||||
|
return mount.Path, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||||
|
//
|
||||||
|
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||||
|
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||||
|
// адресов разошлось бы с первым молча.
|
||||||
|
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||||
|
_, ok := ExactAddressOf(mounts, requestPath)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindApp вешает корень приложения.
|
||||||
|
//
|
||||||
|
// Образцов два, и оба обязательны: без точного `/app` маршрутизатор увёл бы
|
||||||
|
// голый корень перенаправлением на `/app/`, а норма требует от него отказа
|
||||||
|
// приложения, а не переезда.
|
||||||
|
func bindApp(app http.Handler) func(mux *http.ServeMux) {
|
||||||
|
return func(mux *http.ServeMux) {
|
||||||
|
mux.Handle(AppRoot+"/", app)
|
||||||
|
mux.Handle(AppRoot, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindHealth(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc(HealthPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Transcriber service is running",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindMetrics(handler http.Handler) func(mux *http.ServeMux) {
|
||||||
|
return func(mux *http.ServeMux) {
|
||||||
|
mux.Handle(MetricsPath, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,71 +4,71 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
// Проверки разграничения записей по владельцу. Все идут через собранную
|
||||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
// поверхность: сужение живёт в хранилище, но судится по тому, что видит
|
||||||
|
// отправитель.
|
||||||
|
|
||||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
|
||||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
const unknownRecordID = "00000000000000000000000000"
|
||||||
// свой.
|
|
||||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
// newSecondAccount заводит вторую учётную запись со своим логином.
|
||||||
|
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const login = "stranger"
|
account, created, err := env.users.EnsureUser(contract.Identity{
|
||||||
|
Login: "stranger",
|
||||||
users, err := app.FindCollectionByNameOrId("users")
|
Name: "Посторонний",
|
||||||
|
Email: "stranger@example.com",
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
|
||||||
record := core.NewRecord(users)
|
return account
|
||||||
record.Set(migrations.ProviderLoginField, login)
|
|
||||||
record.Set("email", "stranger@example.com")
|
|
||||||
record.Set("verified", true)
|
|
||||||
record.SetRandomPassword()
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
return record, login
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
|
||||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
// ответов обратила бы чтение в перебор — по ней считывается, какие записи
|
||||||
env.mux.ServeHTTP(w, asUser(req, login))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
|
||||||
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
|
|
||||||
// заведены.
|
// заведены.
|
||||||
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
job := jobWithFile(t, env)
|
job := jobWithFile(t, env)
|
||||||
_, stranger := newSecondAccount(t, env.app)
|
newSecondAccount(t, env)
|
||||||
|
|
||||||
foreign := httptest.NewRecorder()
|
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
|
||||||
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
|
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
|
||||||
|
|
||||||
unknown := httptest.NewRecorder()
|
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся")
|
||||||
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
|
|
||||||
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
|
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
|
||||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
||||||
|
|
||||||
// Ни состояния, ни текста расшифровки в теле нет.
|
|
||||||
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
|
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
|
||||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
|
||||||
// её файла.
|
// на границе, и до базы такой запрос не доходит вовсе.
|
||||||
|
func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
malformed := env.getOwn("/app/audiorecords/не-идентификатор")
|
||||||
|
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, malformed.Code)
|
||||||
|
assert.JSONEq(t, unknown.Body.String(), malformed.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у
|
||||||
|
// её копии.
|
||||||
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
@@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
|||||||
|
|
||||||
response := intakeItemOf(t, w)
|
response := intakeItemOf(t, w)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
var owner string
|
||||||
require.NoError(t, err)
|
require.NoError(t, env.db.Reader().
|
||||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||||
|
assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель")
|
||||||
|
|
||||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
|
var fileOwner string
|
||||||
require.NoError(t, err)
|
require.NoError(t, env.db.Reader().
|
||||||
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner))
|
||||||
|
assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Владельца не задают запросом: своё значение в форме на результат не влияет.
|
// Владельца не задают запросом: своё значение в форме на результат не влияет.
|
||||||
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
_, stranger := newSecondAccount(t, env.app)
|
stranger := newSecondAccount(t, env)
|
||||||
|
|
||||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||||
query := req.URL.Query()
|
query := req.URL.Query()
|
||||||
query.Set("owner", stranger)
|
query.Set("owner", stranger.ID)
|
||||||
req.URL.RawQuery = query.Encode()
|
req.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
|||||||
|
|
||||||
response := intakeItemOf(t, w)
|
response := intakeItemOf(t, w)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
var owner string
|
||||||
require.NoError(t, err)
|
require.NoError(t, env.db.Reader().
|
||||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||||
|
assert.Equal(t, env.account.ID, owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
|
// TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим
|
||||||
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
|
// по прямой ссылке, и ответ на него тот же, что и на несуществующую запись.
|
||||||
// коллекции пользователей у него нет, и владельцем записи он стать не может.
|
|
||||||
//
|
//
|
||||||
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
|
// Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
|
||||||
// файл, а уборки файлов сервис не умеет вовсе.
|
// наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
|
||||||
func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
|
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
|
||||||
|
|
||||||
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
admin := core.NewRecord(superusers)
|
|
||||||
admin.Set("email", "owner@example.com")
|
|
||||||
admin.SetRandomPassword()
|
|
||||||
require.NoError(t, env.app.Save(admin))
|
|
||||||
|
|
||||||
token, err := admin.NewAuthToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
|
||||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
|
||||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
|
||||||
req.Header.Set("Authorization", token)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
|
|
||||||
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
|
|
||||||
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
|
|
||||||
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
|
|
||||||
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
|
|
||||||
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
|
|
||||||
// уходит.
|
|
||||||
func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
job := jobWithFile(t, env)
|
job := jobWithFile(t, env)
|
||||||
_, stranger := newSecondAccount(t, env.app)
|
newSecondAccount(t, env)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
|
link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, env.account.Id, record.GetString("owner"))
|
|
||||||
|
|
||||||
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
|
|
||||||
|
|
||||||
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
|
|
||||||
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
|
|
||||||
mine := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(mine,
|
|
||||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
|
||||||
|
|
||||||
foreign := httptest.NewRecorder()
|
|
||||||
env.mux.ServeHTTP(foreign,
|
|
||||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
|
||||||
|
|
||||||
|
mine := env.getOwn(link)
|
||||||
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
||||||
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
||||||
|
|
||||||
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
|
foreign := env.getAs("stranger", link)
|
||||||
|
unknown := env.getAs("stranger",
|
||||||
|
"/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся")
|
||||||
|
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи")
|
||||||
|
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
||||||
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
|
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
// Негодное значение копии у чужой записи неотличимо от неизвестной записи:
|
||||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
// владение судится **до** разбора значения копии. Разойдись эти ответы — по их
|
||||||
//
|
// разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
|
||||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
|
||||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
job := jobWithFile(t, env)
|
||||||
env.mux.ServeHTTP(w, asUser(
|
newSecondAccount(t, env)
|
||||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
|
||||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
|
||||||
|
|
||||||
var body struct {
|
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever")
|
||||||
Token string `json:"token"`
|
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever")
|
||||||
}
|
missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file")
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
||||||
require.NotEmpty(t, body.Token)
|
|
||||||
|
|
||||||
return body.Token
|
assert.Equal(t, http.StatusNotFound, foreign.Code)
|
||||||
|
assert.Equal(t, unknown.Code, foreign.Code)
|
||||||
|
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String())
|
||||||
|
assert.JSONEq(t, missing.Body.String(), foreign.Body.String(),
|
||||||
|
"незаданная копия у чужой записи отвечает не как неизвестная запись")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не
|
||||||
|
// найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной
|
||||||
|
// минуту назад.
|
||||||
|
func TestFileDownload_MissingCopyIsConflict(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
job := jobWithFile(t, env)
|
||||||
|
|
||||||
|
w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
|
||||||
|
var body ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, CodeNotReady, body.Code)
|
||||||
|
|
||||||
|
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||||
|
assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают
|
||||||
|
// отказ по негодному вводу.
|
||||||
|
func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
job := jobWithFile(t, env)
|
||||||
|
|
||||||
|
for name, query := range map[string]string{
|
||||||
|
"копия неизвестна": "?" + CopyParam + "=whatever",
|
||||||
|
"копия не названа": "",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
var body ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, CodeBadRequest, body.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл
|
||||||
|
// целиком.
|
||||||
|
func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
const content = "0123456789abcdef"
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content)))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
server := env.liveServer(t)
|
||||||
|
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||||
|
|
||||||
|
res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"})
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusPartialContent, res.StatusCode)
|
||||||
|
assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок")
|
||||||
|
assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком")
|
||||||
|
assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный
|
||||||
|
// диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки.
|
||||||
|
//
|
||||||
|
// Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом
|
||||||
|
// отображателя ошибки это не проверяется — код `416` рождается не в нём.
|
||||||
|
func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
server := env.liveServer(t)
|
||||||
|
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"неудовлетворимый диапазон": "bytes=99999999-",
|
||||||
|
"два диапазона в запросе": "bytes=0-1,4-5",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rangeHeader := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader})
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`")
|
||||||
|
assert.NotContains(t, res.Header.Get("Content-Type"), "multipart",
|
||||||
|
"сервис отдал ответ из нескольких частей")
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(res.Body, &body))
|
||||||
|
assert.Contains(t, body, "error_code")
|
||||||
|
assert.Contains(t, body, "message")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении,
|
||||||
|
// строится из имени, данного отправителем.
|
||||||
|
func TestFileDownload_CarriesNoStoredName(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
recordID := intakeItemOf(t, created).ID
|
||||||
|
|
||||||
|
w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
names := storedFileNames(t, env)
|
||||||
|
require.Len(t, names, 1)
|
||||||
|
|
||||||
|
headers := w.Result().Header
|
||||||
|
for _, value := range headers.Values("Content-Disposition") {
|
||||||
|
assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ")
|
||||||
|
}
|
||||||
|
assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80",
|
||||||
|
"имя, данное отправителем, в ответ не попало")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится
|
||||||
|
// из рубежа записи, который карточка несёт и так.
|
||||||
|
func TestRecordCard_HasNoCopyList(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
job := jobWithFile(t, env)
|
||||||
|
|
||||||
|
w := env.getOwn("/app/audiorecords/" + job.Id)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||||
|
|
||||||
|
assert.NotContains(t, raw, "available_copies")
|
||||||
|
assert.NotContains(t, raw, "copies")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Своё правило ограничителя частоты под корнем приложения.
|
// Бюджет ограничителя частоты под корнем приложения.
|
||||||
//
|
|
||||||
// Заводится потому, что правило хранилища настроено на **его** корень и наших
|
|
||||||
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
|
|
||||||
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
|
|
||||||
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
|
|
||||||
//
|
//
|
||||||
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
|
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
|
||||||
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
|
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
|
||||||
@@ -22,71 +22,202 @@ const (
|
|||||||
appRateWindowSec = 60
|
appRateWindowSec = 60
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
|
// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
|
||||||
//
|
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
|
||||||
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
|
// спрашивающий кладёт по строке на каждый свой адрес.
|
||||||
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
|
const staleBudgetAge = 10 * time.Minute
|
||||||
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
|
|
||||||
// каталога данных.
|
|
||||||
func ApplyAppRateLimit(app core.App) error {
|
|
||||||
settings := app.Settings()
|
|
||||||
|
|
||||||
rule := core.RateLimitRule{
|
// RateLimit — свой ограничитель частоты под корнем приложения.
|
||||||
Label: AppRoot + "/",
|
//
|
||||||
MaxRequests: appRateMaxRequests,
|
// Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов
|
||||||
Duration: appRateWindowSec,
|
// за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает
|
||||||
|
// доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах
|
||||||
|
// приложения не появляется.
|
||||||
|
//
|
||||||
|
// **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда
|
||||||
|
// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком
|
||||||
|
// другом случае адресом считается адрес пира, а пришедший заголовок на ключ
|
||||||
|
// бюджета не влияет ничем.
|
||||||
|
//
|
||||||
|
// **Цепочка пересылки читается справа налево до первого недоверенного адреса.**
|
||||||
|
// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок
|
||||||
|
// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного.
|
||||||
|
//
|
||||||
|
// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси,
|
||||||
|
// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на
|
||||||
|
// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка —
|
||||||
|
// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого
|
||||||
|
// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||||||
|
// запрос, и меняет он его на каждом запросе.
|
||||||
|
//
|
||||||
|
// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||||||
|
// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||||||
|
// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в
|
||||||
|
// адресе соединения не виден вовсе.
|
||||||
|
//
|
||||||
|
// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и
|
||||||
|
// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах,
|
||||||
|
// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные
|
||||||
|
// записи, которые потом не убираются ничем.
|
||||||
|
func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler {
|
||||||
|
limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !limiter.allow(clientAddress(r, trusted)) {
|
||||||
|
fail(w, contract.ErrTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientAddress — тот, кого ограничивают.
|
||||||
|
func clientAddress(r *http.Request, trusted []netip.Prefix) string {
|
||||||
|
peer, ok := peerAddress(r.RemoteAddr)
|
||||||
|
if !ok {
|
||||||
|
// Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет
|
||||||
|
// лучше отсутствующего.
|
||||||
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced := false
|
if !isTrusted(trusted, peer) {
|
||||||
for i, existing := range settings.RateLimits.Rules {
|
return peer.String()
|
||||||
if existing.Label == rule.Label {
|
}
|
||||||
settings.RateLimits.Rules[i] = rule
|
|
||||||
replaced = true
|
if client, ok := forwardedClient(r, trusted); ok {
|
||||||
break
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прокси адреса не назвал либо назвал одними доверенными: счётчик падает
|
||||||
|
// обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который
|
||||||
|
// выбирает сам спрашивающий.
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedClient достаёт из цепочки пересылки того, кого ограничивают.
|
||||||
|
//
|
||||||
|
// **Цепочка читается справа налево, а доверенные адреса отбрасываются.**
|
||||||
|
// Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а
|
||||||
|
// не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ
|
||||||
|
// бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится
|
||||||
|
// с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и
|
||||||
|
// каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный
|
||||||
|
// справа и есть последний адрес, за который отвечает контур, а не спрашивающий.
|
||||||
|
//
|
||||||
|
// Заголовок читается **всеми** строками, а не одной: цепочка законно приходит
|
||||||
|
// несколькими заголовками, и разбор одной строки увидел бы только её часть — то
|
||||||
|
// есть снова кусок, которым распоряжается аноним.
|
||||||
|
func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) {
|
||||||
|
hops := forwardedChain(r)
|
||||||
|
|
||||||
|
for i := len(hops) - 1; i >= 0; i-- {
|
||||||
|
addr, err := netip.ParseAddr(hops[i])
|
||||||
|
if err != nil {
|
||||||
|
// Значение, которое не разбирается адресом, доверенным считать
|
||||||
|
// нечем: цепочка на нём кончается.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if isTrusted(trusted, addr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedChain — цепочка пересылки одним списком: все строки заголовка по
|
||||||
|
// порядку, каждая разобрана по запятым.
|
||||||
|
func forwardedChain(r *http.Request) []string {
|
||||||
|
var hops []string
|
||||||
|
for _, value := range r.Header.Values(ForwardedForHeader) {
|
||||||
|
for _, hop := range strings.Split(value, ",") {
|
||||||
|
hop = strings.TrimSpace(hop)
|
||||||
|
if hop != "" {
|
||||||
|
hops = append(hops, hop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !replaced {
|
return hops
|
||||||
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
|
}
|
||||||
|
|
||||||
|
// rateLimiter — счётчики бюджета по ключу.
|
||||||
|
//
|
||||||
|
// Окно скользящее и хранится метками времени: счётчик с обнулением по границе
|
||||||
|
// окна пропустил бы двойной бюджет на стыке двух окон.
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
max int
|
||||||
|
window time.Duration
|
||||||
|
budgets map[string]*budget
|
||||||
|
swept time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type budget struct {
|
||||||
|
hits []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
max: maxRequests,
|
||||||
|
window: window,
|
||||||
|
budgets: map[string]*budget{},
|
||||||
|
swept: clock.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *rateLimiter) allow(key string) bool {
|
||||||
|
now := clock.Now()
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.sweep(now)
|
||||||
|
|
||||||
|
b, ok := l.budgets[key]
|
||||||
|
if !ok {
|
||||||
|
b = &budget{}
|
||||||
|
l.budgets[key] = b
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.RateLimits.Enabled = true
|
b.hits = fresh(b.hits, now.Add(-l.window))
|
||||||
|
if len(b.hits) >= l.max {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Save(settings); err != nil {
|
b.hits = append(b.hits, now)
|
||||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
return true
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
|
||||||
// адрес спрашивающего.
|
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
|
||||||
//
|
func (l *rateLimiter) sweep(now time.Time) {
|
||||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
if now.Sub(l.swept) < l.window {
|
||||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
return
|
||||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
}
|
||||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
l.swept = now
|
||||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
|
||||||
// спрашивающего.
|
|
||||||
//
|
|
||||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
|
||||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
|
||||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
|
||||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
|
||||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
|
||||||
// счётчика сам и обходит ограничитель, меняя значение.
|
|
||||||
//
|
|
||||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
|
||||||
// по-прежнему решает адрес самого соединения.
|
|
||||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
|
||||||
settings := app.Settings()
|
|
||||||
|
|
||||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
for key, b := range l.budgets {
|
||||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
|
||||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
if len(b.hits) == 0 {
|
||||||
// чем один пустой ключ на всех.
|
delete(l.budgets, key)
|
||||||
settings.TrustedProxy.UseLeftmostIP = true
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Save(settings); err != nil {
|
// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому
|
||||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
// достаточно найти первую свежую.
|
||||||
|
func fresh(hits []time.Time, edge time.Time) []time.Time {
|
||||||
|
for i, hit := range hits {
|
||||||
|
if hit.After(edge) {
|
||||||
|
return hits[i:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppChain одевает адреса приложения в их слои.
|
||||||
|
//
|
||||||
|
// Порядок один и он несущий: ограничитель частоты → узнавание → требование
|
||||||
|
// учётной записи → обработчик.
|
||||||
|
//
|
||||||
|
// - ограничитель стоит первым, потому что узнавание читает базу, а на новом
|
||||||
|
// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах,
|
||||||
|
// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы
|
||||||
|
// учётные записи, которые потом не убираются ничем;
|
||||||
|
// - требование учётной записи стоит перед чтением тела: запись, за которую не
|
||||||
|
// заплатит узнанный отправитель, не должна попасть даже в память, а позже
|
||||||
|
// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе.
|
||||||
|
//
|
||||||
|
// Область действия всех трёх — корень приложения, и берётся она из перечня
|
||||||
|
// адресного пространства: цепочка вешается на корень целиком, вторым списком
|
||||||
|
// адресов её не описывают.
|
||||||
|
func AppChain(
|
||||||
|
routes http.Handler,
|
||||||
|
users contract.UserRepository,
|
||||||
|
trusted []netip.Prefix,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) http.Handler {
|
||||||
|
handler := RequireUser()(routes)
|
||||||
|
handler = TrustedHeaderIdentity(users, trusted, logger)(handler)
|
||||||
|
handler = RateLimit(trusted)(handler)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildHandler собирает поверхность сервиса целиком.
|
||||||
|
//
|
||||||
|
// Раздача приложения вешается последней: она занимает корень, и всё, что не
|
||||||
|
// совпало ни с одним адресом сервиса, доходит до неё.
|
||||||
|
//
|
||||||
|
// Снаружи всех — журнал и восстановление после паники: первый обязан видеть код
|
||||||
|
// ответа, каким бы слоем тот ни родился, второй — не дать одному паникующему
|
||||||
|
// запросу уронить процесс.
|
||||||
|
func BuildHandler(mounts []Mount, webapp *WebappHandler, logger *slog.Logger) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
RegisterServiceRoutes(mux, mounts)
|
||||||
|
webapp.Register(mux)
|
||||||
|
|
||||||
|
return Journal(mounts, logger)(Recover(logger)(mux))
|
||||||
|
}
|
||||||
@@ -8,12 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||||
@@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
structures := pbrepo.NewStructureRepository(env.app)
|
structures := env.handler.structureRepo
|
||||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{
|
structure, err := structures.Put(record.Id, 1, []entity.Replica{
|
||||||
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
|
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
|
||||||
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
|
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
|
||||||
@@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.TranscriptTextID = &transcript.Id
|
record.TranscriptTextID = &transcript.Id
|
||||||
@@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
|||||||
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
|
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом.
|
||||||
|
//
|
||||||
|
// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым,
|
||||||
|
// структуры реплик нет, структура заведена без единой реплики. Шаг завершения
|
||||||
|
// пишет результат несколькими операциями, поэтому запись законно застаётся между
|
||||||
|
// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого
|
||||||
|
// нет, либо показала бы «записи нет» на своей записи.
|
||||||
|
func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := textOf(t, env, record.Id, view)
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code, view)
|
||||||
|
|
||||||
|
var body ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, CodeNotReady, body.Code, view)
|
||||||
|
assert.NotEmpty(t, body.Message, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("текста нет вовсе", func(t *testing.T) {
|
||||||
|
record := jobWithFile(t, env)
|
||||||
|
notReady(t, record, entity.TextViewTranscript)
|
||||||
|
notReady(t, record, entity.TextViewLiterary)
|
||||||
|
notReady(t, record, entity.TextViewReplicas)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("текст заведён пустым", func(t *testing.T) {
|
||||||
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
|
transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.TranscriptTextID = &transcript.Id
|
||||||
|
|
||||||
|
literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.LiteraryTextID = &literary.Id
|
||||||
|
|
||||||
|
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||||
|
|
||||||
|
notReady(t, record, entity.TextViewTranscript)
|
||||||
|
notReady(t, record, entity.TextViewLiterary)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("структура заведена без единой реплики", func(t *testing.T) {
|
||||||
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
|
structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.StructureID = &structure.Id
|
||||||
|
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||||
|
|
||||||
|
notReady(t, record, entity.TextViewReplicas)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
|
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
|
||||||
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
||||||
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
|
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
|
||||||
@@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
|||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
|
||||||
texts := pbrepo.NewTextRepository(env.app)
|
texts := env.handler.textRepo
|
||||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
|||||||
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
||||||
// цепочка та же, что и в проде.
|
// цепочка та же, что и в проде.
|
||||||
journal := &journalBuffer{}
|
journal := &journalBuffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
handler := NewAppHandler(
|
handler := NewAppHandler(
|
||||||
env.handler.recordRepo,
|
env.handler.recordRepo,
|
||||||
&failingTextRepo{},
|
&failingTextRepo{},
|
||||||
pbrepo.NewStructureRepository(env.app),
|
env.handler.structureRepo,
|
||||||
|
env.handler.fileRepo,
|
||||||
env.handler.trsService,
|
env.handler.trsService,
|
||||||
slog.New(slog.NewTextHandler(journal, nil)),
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
r, err := apis.NewRouter(env.app)
|
mounts := ServiceMounts(
|
||||||
require.NoError(t, err)
|
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
|
||||||
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
|
http.NotFoundHandler(),
|
||||||
handler.Register(r)
|
)
|
||||||
mux, err := r.BuildMux()
|
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||||
asUser(req, env.login)
|
asUser(req, env.login)
|
||||||
@@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
|||||||
|
|
||||||
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
|
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
|
||||||
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
|
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
|
||||||
|
//
|
||||||
|
// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, —
|
||||||
|
// разметкой приложения: корень `/api` перестал существовать вместе со
|
||||||
|
// встроенным хранилищем, и резервировать имя за отказом сервис не берётся.
|
||||||
func TestFormerAddressesAreGone(t *testing.T) {
|
func TestFormerAddressesAreGone(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
record := jobWithFile(t, env)
|
record := jobWithFile(t, env)
|
||||||
|
before := countJobs(t, env)
|
||||||
|
|
||||||
status := httptest.NewRecorder()
|
status := httptest.NewRecorder()
|
||||||
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
|
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
|
||||||
assert.Equal(t, http.StatusNotFound, status.Code)
|
assert.Equal(t, http.StatusOK, status.Code)
|
||||||
|
assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
|
||||||
|
assert.NotContains(t, status.Body.String(), entity.StateUploaded,
|
||||||
|
"прежний адрес отдал данные записи")
|
||||||
|
|
||||||
intake := httptest.NewRecorder()
|
intake := httptest.NewRecorder()
|
||||||
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
|
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
|
||||||
assert.Equal(t, http.StatusNotFound, intake.Code)
|
assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
|
||||||
|
assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,20 +17,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer {
|
|||||||
return &stubMetaViewer{seconds: 42}
|
return &stubMetaViewer{seconds: 42}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
||||||
|
|
||||||
|
// builtDist — собранное приложение, каким его видит раздача.
|
||||||
|
func builtDist() fs.FS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte(testMarkup)},
|
||||||
|
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
||||||
|
"favicon.ico": {Data: []byte("значок")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
||||||
// рабочий каталог процесса проверки не трогают.
|
// рабочий каталог процесса проверки не трогают.
|
||||||
type testEnv struct {
|
type testEnv struct {
|
||||||
mux http.Handler
|
mux http.Handler
|
||||||
handler *AppHandler
|
handler *AppHandler
|
||||||
app core.App
|
db *sqliterepo.DB
|
||||||
|
store *sqliterepo.Store
|
||||||
|
users *sqliterepo.UserRepository
|
||||||
|
files *sqliterepo.FileRepository
|
||||||
journal *journalBuffer
|
journal *journalBuffer
|
||||||
// login — логин у провайдера, которым доверенный источник называет
|
// login — логин у провайдера, которым доверенный источник называет
|
||||||
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
||||||
// по существу, обязана назваться ровно так же, как это делает прокси.
|
// по существу, обязана назваться ровно так же, как это делает прокси.
|
||||||
login string
|
login string
|
||||||
// account — учётная запись, которой принадлежит этот логин.
|
// account — учётная запись, которой принадлежит этот логин.
|
||||||
account *core.Record
|
account *contract.UserAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
// Доверенный источник проверок.
|
// Доверенный источник проверок.
|
||||||
@@ -93,10 +108,68 @@ const (
|
|||||||
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
// Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
|
||||||
|
// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
|
||||||
|
// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
|
||||||
|
networks := make([]netip.Prefix, 0, 2)
|
||||||
|
for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
|
||||||
|
prefix, err := netip.ParsePrefix(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
networks = append(networks, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return networks
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
|
||||||
|
//
|
||||||
|
// Нужен там, где проверка судит поведение, которого у вызова обработчика
|
||||||
|
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
|
||||||
|
// адрес пира ставит ядро.
|
||||||
|
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(e.mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
|
||||||
|
// проверке остаётся судить значения.
|
||||||
|
type liveResponse struct {
|
||||||
|
StatusCode int
|
||||||
|
Header http.Header
|
||||||
|
ContentLength int64
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
|
||||||
|
func (e *testEnv) liveRequest(
|
||||||
|
t *testing.T, server *httptest.Server, path string, headers map[string]string,
|
||||||
|
) liveResponse {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set(LoginHeader, e.login)
|
||||||
|
for name, value := range headers {
|
||||||
|
req.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := server.Client().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return []netip.Prefix{prefix}
|
return liveResponse{
|
||||||
|
StatusCode: res.StatusCode,
|
||||||
|
Header: res.Header,
|
||||||
|
ContentLength: res.ContentLength,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
||||||
@@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
|||||||
e.mux.ServeHTTP(w, asUser(req, e.login))
|
e.mux.ServeHTTP(w, asUser(req, e.login))
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestAccount заводит учётную запись с известным логином.
|
// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
|
||||||
//
|
func (e *testEnv) get(path string) *httptest.ResponseRecorder {
|
||||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
w := httptest.NewRecorder()
|
||||||
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
|
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
// правил доступа так же, как идёт заведение записи первым обращением.
|
return w
|
||||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
}
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const login = "person"
|
// getAs шлёт запрос от имени названного логина.
|
||||||
|
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId("users")
|
// getOwn шлёт запрос от имени учётной записи проверки.
|
||||||
require.NoError(t, err)
|
func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
|
||||||
|
return e.getAs(e.login, path)
|
||||||
record := core.NewRecord(users)
|
|
||||||
record.Set(migrations.ProviderLoginField, login)
|
|
||||||
record.Set("email", "person@example.com")
|
|
||||||
record.Set("verified", true)
|
|
||||||
// Случайный пароль ставит и сам сервис, когда заводит запись первым
|
|
||||||
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
|
|
||||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
|
||||||
record.SetRandomPassword()
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
return record, login
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||||
@@ -162,45 +228,72 @@ func (b *journalBuffer) String() string {
|
|||||||
return b.text.String()
|
return b.text.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
|
||||||
// ровно тем же путём, каким это делает сервис при старте.
|
// тем же путём, каким это делает сервис при старте.
|
||||||
func newTestStorage(t *testing.T) core.App {
|
func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app, err := pbrepo.New(t.TempDir())
|
dir := t.TempDir()
|
||||||
|
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
if err := app.ResetBootstrapState(); err != nil {
|
if err := db.Close(); err != nil {
|
||||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
t.Logf("не удалось закрыть базу: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return app
|
require.NoError(t, sqliterepo.Migrate(
|
||||||
|
context.Background(), db, dir, slog.New(slog.DiscardHandler),
|
||||||
|
))
|
||||||
|
|
||||||
|
return db, sqliterepo.NewStore(dir), dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOptions — чем одна проверка отличается от другой.
|
||||||
|
type envOptions struct {
|
||||||
|
metaviewer contract.AudioMetaViewer
|
||||||
|
dist fs.FS
|
||||||
|
built bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||||
app := newTestStorage(t)
|
return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
|
||||||
|
}
|
||||||
|
|
||||||
pbrepo.BindPanelRules(app)
|
// setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
|
||||||
|
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
|
||||||
|
// Собери проверка поверхность иначе — и судила бы она не то.
|
||||||
|
func setupEnv(t *testing.T, opts envOptions) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, store, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||||
|
textRepo := sqliterepo.NewTextRepository(db)
|
||||||
|
structureRepo := sqliterepo.NewStructureRepository(db)
|
||||||
|
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||||
|
users := sqliterepo.NewUserRepository(db)
|
||||||
|
|
||||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
|
||||||
textRepo := pbrepo.NewTextRepository(app)
|
|
||||||
repos := service.Repositories{
|
repos := service.Repositories{
|
||||||
Records: recordRepo,
|
Records: recordRepo,
|
||||||
Files: pbrepo.NewFileRepository(app),
|
Files: fileRepo,
|
||||||
Texts: textRepo,
|
Texts: textRepo,
|
||||||
Structures: pbrepo.NewStructureRepository(app),
|
Structures: structureRepo,
|
||||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||||
Events: pbrepo.NewRecordEventRepository(app),
|
Events: sqliterepo.NewRecordEventRepository(db),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||||
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
// отказа по-прежнему не попадают на экран.
|
||||||
// которому отличают новый красный шаг гейта от объявленного долга.
|
|
||||||
journal := &journalBuffer{}
|
journal := &journalBuffer{}
|
||||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
metaviewer := opts.metaviewer
|
||||||
|
if metaviewer == nil {
|
||||||
|
metaviewer = readableMetaViewer()
|
||||||
|
}
|
||||||
|
|
||||||
trsService := service.NewTranscribeService(
|
trsService := service.NewTranscribeService(
|
||||||
repos,
|
repos,
|
||||||
metaviewer,
|
metaviewer,
|
||||||
@@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
|
||||||
|
|
||||||
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
mounts := ServiceMounts(
|
||||||
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
|
||||||
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
http.NotFoundHandler(),
|
||||||
r, err := apis.NewRouter(app)
|
)
|
||||||
|
webapp := NewWebappHandler(opts.dist, opts.built, logger)
|
||||||
|
mux := BuildHandler(mounts, webapp, logger)
|
||||||
|
|
||||||
|
account, _, err := users.EnsureUser(contract.Identity{
|
||||||
|
Login: "person",
|
||||||
|
Name: "Человек",
|
||||||
|
Email: "person@example.com",
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
mounts := ServiceMounts(handler, http.NotFoundHandler())
|
|
||||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
|
||||||
handler.Register(r)
|
|
||||||
|
|
||||||
mux, err := r.BuildMux()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
account, login := newTestAccount(t, app)
|
|
||||||
|
|
||||||
return &testEnv{
|
return &testEnv{
|
||||||
mux: mux,
|
mux: mux,
|
||||||
handler: handler,
|
handler: handler,
|
||||||
app: app,
|
db: db,
|
||||||
|
store: store,
|
||||||
|
users: users,
|
||||||
|
files: fileRepo,
|
||||||
journal: journal,
|
journal: journal,
|
||||||
login: login,
|
login: "person",
|
||||||
account: account,
|
account: account,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
|
|||||||
return items[0]
|
return items[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
// storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
|
||||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, rows.Close()) }()
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
for _, record := range records {
|
for rows.Next() {
|
||||||
names = append(names, record.GetStringSlice("file")...)
|
var name string
|
||||||
|
require.NoError(t, rows.Scan(&name))
|
||||||
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
require.NoError(t, rows.Err())
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// countFiles считает записи о файлах.
|
// countRows считает строки названной таблицы.
|
||||||
|
func countRows(t *testing.T, env *testEnv, table string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var total int
|
||||||
|
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFiles считает строки о копиях записей.
|
||||||
func countFiles(t *testing.T, env *testEnv) int {
|
func countFiles(t *testing.T, env *testEnv) int {
|
||||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
return countRows(t, env, "files")
|
||||||
require.NoError(t, err)
|
|
||||||
return len(records)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// countJobs считает заведённые аудиозаписи.
|
// countJobs считает заведённые аудиозаписи.
|
||||||
func countJobs(t *testing.T, env *testEnv) int {
|
func countJobs(t *testing.T, env *testEnv) int {
|
||||||
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
return countRows(t, env, "audio_records")
|
||||||
require.NoError(t, err)
|
|
||||||
return len(records)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
|
||||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
// запись не прошла бы ни одного шага.
|
||||||
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
repo := pbrepo.NewFileRepository(env.app)
|
return jobWithFileOf(t, env, env.account.ID)
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
}
|
||||||
|
|
||||||
|
// jobWithFileOf — то же, но от имени названного владельца.
|
||||||
|
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
recordID := ident.New()
|
||||||
|
|
||||||
|
work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
defer func() { require.NoError(t, work.Close()) }()
|
||||||
|
|
||||||
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
|
// Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
|
||||||
// владельца больше не заводится, и фикстура без него описывала бы состояние,
|
// фикстура без владельца описывала бы состояние, которого в проде нет.
|
||||||
// которого в проде не бывает.
|
file, err := env.files.Create(
|
||||||
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
|
recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record := &entity.AudioRecord{
|
record := &entity.AudioRecord{
|
||||||
|
Id: recordID,
|
||||||
State: entity.StateUploaded,
|
State: entity.StateUploaded,
|
||||||
StateEnteredAt: clock.Now(),
|
StateEnteredAt: clock.Now(),
|
||||||
Source: entity.SourceApi,
|
OwnerID: ownerID,
|
||||||
OwnerID: env.account.Id,
|
|
||||||
OriginalFileID: &file.Id,
|
OriginalFileID: &file.Id,
|
||||||
}
|
}
|
||||||
require.NoError(t, env.handler.recordRepo.Create(record))
|
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedContent читает содержимое файла из хранилища.
|
// newTopic заводит тему в словаре названного владельца.
|
||||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
//
|
||||||
repo := pbrepo.NewFileRepository(env.app)
|
// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
|
||||||
reader, err := repo.Open(fileID)
|
// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
|
||||||
|
// пройти тот же путь разрешения тем, каким пойдёт она.
|
||||||
|
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
id := ident.New()
|
||||||
|
now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||||
|
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id, ownerID, name, now, now,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer reader.Close()
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachTopic связывает тему с записью.
|
||||||
|
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||||
|
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedContent читает содержимое копии из каталога данных.
|
||||||
|
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, err := env.files.Open(fileID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, reader.Close()) }()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_, err = buf.ReadFrom(reader)
|
_, err = buf.ReadFrom(reader)
|
||||||
@@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
|||||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||||
require.Equal(t, 1, countJobs(t, env))
|
require.Equal(t, 1, countJobs(t, env))
|
||||||
|
|
||||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateUploaded, job.State)
|
assert.Equal(t, entity.StateUploaded, job.State)
|
||||||
require.NotNil(t, job.OriginalFileID)
|
require.NotNil(t, job.OriginalFileID)
|
||||||
@@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
|||||||
require.Len(t, names, 1)
|
require.Len(t, names, 1)
|
||||||
|
|
||||||
assert.NotContains(t, names[0], "секретное-слово",
|
assert.NotContains(t, names[0], "секретное-слово",
|
||||||
"имя, данное отправителем, в хранилище не попадает")
|
"имя, данное отправителем, в имя файла не попадает")
|
||||||
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
||||||
"расширение при этом сохраняется: %q", names[0])
|
"расширение при этом сохраняется: %q", names[0])
|
||||||
|
|
||||||
|
// И в **путь** к файлу оно не попадает тоже: подкаталог назван
|
||||||
|
// идентификатором записи, а не именем отправителя.
|
||||||
|
var recordID, storedRecord string
|
||||||
|
require.NoError(t, env.db.Reader().
|
||||||
|
QueryRow("SELECT id FROM audio_records").Scan(&recordID))
|
||||||
|
require.NoError(t, env.db.Reader().
|
||||||
|
QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
|
||||||
|
assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
|
||||||
|
assert.NotContains(t, storedRecord, "секретное-слово",
|
||||||
|
"имя, данное отправителем, попало в путь к файлу")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||||
@@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
|||||||
|
|
||||||
response := intakeItemOf(t, w)
|
response := intakeItemOf(t, w)
|
||||||
|
|
||||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, job.OriginalFileID)
|
require.NotNil(t, job.OriginalFileID)
|
||||||
|
|
||||||
@@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,29 +9,6 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Корни адресного пространства и отдельные адреса наблюдения.
|
|
||||||
//
|
|
||||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
|
||||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
|
||||||
//
|
|
||||||
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
|
|
||||||
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
|
|
||||||
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
|
||||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
|
||||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
|
||||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
|
||||||
const (
|
|
||||||
StorageRoot = "/api"
|
|
||||||
PanelRoot = "/_"
|
|
||||||
|
|
||||||
HealthPath = "/health"
|
|
||||||
MetricsPath = "/metrics"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// assetsDir — каталог, который наполняет сборщик приложения.
|
// assetsDir — каталог, который наполняет сборщик приложения.
|
||||||
@@ -45,104 +22,6 @@ const assetsDir = "assets"
|
|||||||
// Срок хранения ресурса сборщика — год.
|
// Срок хранения ресурса сборщика — год.
|
||||||
const assetMaxAgeSeconds = 31536000
|
const assetMaxAgeSeconds = 31536000
|
||||||
|
|
||||||
// Mount — часть адресного пространства, принадлежащая сервису.
|
|
||||||
//
|
|
||||||
// Перечень этих частей — **единственное** описание того, что сервису
|
|
||||||
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
|
||||||
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
|
|
||||||
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
|
|
||||||
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
|
|
||||||
// контракта.
|
|
||||||
type Mount struct {
|
|
||||||
// Path — корень либо точный адрес.
|
|
||||||
Path string
|
|
||||||
|
|
||||||
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
|
||||||
// только сам себя, а `/app` — всё, что под ним.
|
|
||||||
Exact bool
|
|
||||||
|
|
||||||
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
|
|
||||||
Bind func(r *router.Router[*core.RequestEvent])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
|
||||||
//
|
|
||||||
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
|
||||||
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
|
||||||
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
|
|
||||||
// никому и уехал бы разметкой приложения.
|
|
||||||
func (m Mount) Covers(requestPath string) bool {
|
|
||||||
if m.Exact {
|
|
||||||
return requestPath == m.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
|
||||||
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
|
|
||||||
return []Mount{
|
|
||||||
{Path: StorageRoot},
|
|
||||||
{Path: PanelRoot},
|
|
||||||
{Path: AppRoot, Bind: appHandler.Register},
|
|
||||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
|
||||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
|
||||||
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
|
|
||||||
for _, mount := range mounts {
|
|
||||||
if mount.Bind != nil {
|
|
||||||
mount.Bind(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
|
|
||||||
func IsServiceAddress(mounts []Mount, requestPath string) bool {
|
|
||||||
for _, mount := range mounts {
|
|
||||||
if mount.Covers(requestPath) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
|
||||||
//
|
|
||||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
|
||||||
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
|
||||||
// адресов разошлось бы с первым молча.
|
|
||||||
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
|
||||||
for _, mount := range mounts {
|
|
||||||
if mount.Exact && mount.Covers(requestPath) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindHealth(r *router.Router[*core.RequestEvent]) {
|
|
||||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{
|
|
||||||
"status": "ok",
|
|
||||||
"message": "Transcriber service is running",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
|
|
||||||
return func(r *router.Router[*core.RequestEvent]) {
|
|
||||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
|
||||||
handler.ServeHTTP(e.Response, e.Request)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
||||||
//
|
//
|
||||||
// Состояние возможно только у собранного мимо набора проверок: и набор
|
// Состояние возможно только у собранного мимо набора проверок: и набор
|
||||||
@@ -164,25 +43,10 @@ const (
|
|||||||
OutcomeFailure = "failure"
|
OutcomeFailure = "failure"
|
||||||
)
|
)
|
||||||
|
|
||||||
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
|
|
||||||
const journalOutcomeKey = "transcriberWebappOutcome"
|
|
||||||
|
|
||||||
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
|
|
||||||
// запрос до неё не дошёл.
|
|
||||||
func WebappOutcome(e *core.RequestEvent) string {
|
|
||||||
outcome, ok := e.Get(journalOutcomeKey).(string)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
||||||
type WebappHandler struct {
|
type WebappHandler struct {
|
||||||
dist fs.FS
|
dist fs.FS
|
||||||
built bool
|
built bool
|
||||||
mounts []Mount
|
|
||||||
fingerprint string
|
fingerprint string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -191,14 +55,14 @@ type WebappHandler struct {
|
|||||||
//
|
//
|
||||||
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
||||||
// подставляет свою сборку, не собирая приложение.
|
// подставляет свою сборку, не собирая приложение.
|
||||||
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
|
func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !built {
|
if !built {
|
||||||
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
||||||
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
|
return &WebappHandler{dist: dist, built: built, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
||||||
@@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge
|
|||||||
return &WebappHandler{
|
return &WebappHandler{
|
||||||
dist: dist,
|
dist: dist,
|
||||||
built: built,
|
built: built,
|
||||||
mounts: mounts,
|
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
||||||
//
|
|
||||||
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
|
|
||||||
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
|
|
||||||
// ресурсов, то есть при всякой пересборке с изменениями.
|
|
||||||
func buildFingerprint(dist fs.FS) string {
|
func buildFingerprint(dist fs.FS) string {
|
||||||
markup, err := fs.ReadFile(dist, "index.html")
|
markup, err := fs.ReadFile(dist, "index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string {
|
|||||||
|
|
||||||
// Register вешает раздачу на корень.
|
// Register вешает раздачу на корень.
|
||||||
//
|
//
|
||||||
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
|
// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
|
||||||
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
|
// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
|
||||||
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
|
// поэтому остаётся отказом контракта, а не проваливается в разметку.
|
||||||
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
|
func (h *WebappHandler) Register(mux *http.ServeMux) {
|
||||||
// Успешная раздача в журнал хранилища не пишется.
|
mux.HandleFunc("/", h.Serve)
|
||||||
//
|
|
||||||
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
|
|
||||||
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
|
|
||||||
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
|
|
||||||
// открытие приложения оставляло бы там его текст и его адрес. Готовая
|
|
||||||
// раздача статики ставит тот же признак первой строкой; своя написана мимо
|
|
||||||
// неё, и признак перенесён руками.
|
|
||||||
//
|
|
||||||
// Отказы записываются по-прежнему: признак снимает только успех.
|
|
||||||
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
|
// Serve отдаёт приложение либо отказ.
|
||||||
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||||
requestPath := e.Request.URL.Path
|
// Открывающими страницу считаются `GET` и `HEAD`, и только они.
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
|
noteWebappOutcome(r, OutcomeFailure)
|
||||||
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
// отвечает и сегодня: своя форма здесь была бы второй.
|
return
|
||||||
if IsServiceAddress(h.mounts, requestPath) {
|
|
||||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
|
||||||
return router.NewNotFoundError("", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
|
||||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
|
||||||
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.built {
|
if !h.built {
|
||||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
noteWebappOutcome(r, OutcomeFailure)
|
||||||
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte(notBuiltPage))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimPrefix(path.Clean(requestPath), "/")
|
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
if name == "" || name == "." {
|
if name == "" || name == "." {
|
||||||
return h.serveIndex(e)
|
h.serveIndex(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
||||||
e.Set(journalOutcomeKey, OutcomeAsset)
|
noteWebappOutcome(r, OutcomeAsset)
|
||||||
h.setCacheHeader(e, name)
|
h.setCacheHeader(w, name)
|
||||||
return e.FileFS(h.dist, name)
|
http.ServeFileFS(w, r, h.dist, name)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||||
@@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
|||||||
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
||||||
// не останется ничего.
|
// не останется ничего.
|
||||||
if underAssets(name) {
|
if underAssets(name) {
|
||||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
noteWebappOutcome(r, OutcomeFailure)
|
||||||
return router.NewNotFoundError("", nil)
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.serveIndex(e)
|
h.serveIndex(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
|
func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
e.Set(journalOutcomeKey, OutcomeMarkup)
|
noteWebappOutcome(r, OutcomeMarkup)
|
||||||
h.setCacheHeader(e, "index.html")
|
h.setCacheHeader(w, "index.html")
|
||||||
|
|
||||||
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
||||||
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
||||||
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
||||||
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
|
// подтверждения.
|
||||||
if h.fingerprint != "" {
|
if h.fingerprint != "" {
|
||||||
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
w.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.FileFS(h.dist, "index.html")
|
http.ServeFileFS(w, r, h.dist, "index.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
|
||||||
//
|
|
||||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
|
||||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
|
||||||
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
|
|
||||||
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
||||||
//
|
//
|
||||||
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
||||||
@@ -327,14 +169,19 @@ func underAssets(name string) bool {
|
|||||||
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
|
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||||
|
//
|
||||||
|
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||||
|
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||||
|
// отзывается со стороны сервиса ничем.
|
||||||
|
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
|
||||||
if underAssets(name) {
|
if underAssets(name) {
|
||||||
e.Response.Header().Set(
|
w.Header().Set(
|
||||||
"Cache-Control",
|
"Cache-Control",
|
||||||
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.Response.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,88 +2,25 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
// webappEnv — окружение проверок раздачи. Поверхность собирается тем же
|
||||||
|
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
|
||||||
// builtDist — собранное приложение, каким его видит раздача.
|
// не перечисление в проверке.
|
||||||
func builtDist() fs.FS {
|
|
||||||
return fstest.MapFS{
|
|
||||||
"index.html": {Data: []byte(testMarkup)},
|
|
||||||
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
|
||||||
"favicon.ico": {Data: []byte("значок")},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
|
|
||||||
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
|
||||||
type webappEnv struct {
|
type webappEnv struct {
|
||||||
mux http.Handler
|
env *testEnv
|
||||||
journal *journalBuffer
|
|
||||||
login string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := newTestStorage(t)
|
return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
|
||||||
pbrepo.BindPanelRules(app)
|
|
||||||
|
|
||||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
|
||||||
textRepo := pbrepo.NewTextRepository(app)
|
|
||||||
structureRepo := pbrepo.NewStructureRepository(app)
|
|
||||||
|
|
||||||
journal := &journalBuffer{}
|
|
||||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
|
||||||
|
|
||||||
trsService := service.NewTranscribeService(
|
|
||||||
service.Repositories{
|
|
||||||
Records: recordRepo,
|
|
||||||
Files: pbrepo.NewFileRepository(app),
|
|
||||||
Texts: textRepo,
|
|
||||||
Structures: structureRepo,
|
|
||||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
|
||||||
Events: pbrepo.NewRecordEventRepository(app),
|
|
||||||
},
|
|
||||||
readableMetaViewer(),
|
|
||||||
&stubConverter{},
|
|
||||||
&recognizer.MemoryAudioRecognizer{},
|
|
||||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
|
||||||
logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
|
||||||
|
|
||||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
|
||||||
|
|
||||||
r, err := apis.NewRouter(app)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
|
||||||
RegisterServiceRoutes(r, mounts)
|
|
||||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
|
||||||
|
|
||||||
mux, err := r.BuildMux()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, login := newTestAccount(t, app)
|
|
||||||
|
|
||||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||||
@@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
|||||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(method, path, nil)
|
req := httptest.NewRequest(method, path, nil)
|
||||||
if identified {
|
if identified {
|
||||||
asUser(req, e.login)
|
asUser(req, e.env.login)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
e.mux.ServeHTTP(rec, req)
|
e.env.mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// journal отдаёт перехваченный журнал окружения.
|
||||||
|
func (e *webappEnv) journal() string {
|
||||||
|
return e.env.journal.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
||||||
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
||||||
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||||
@@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
|||||||
|
|
||||||
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
||||||
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
||||||
|
//
|
||||||
|
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
|
||||||
|
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
|
||||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||||
env := setupWebappEnv(t, builtDist(), true)
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
for _, path := range []string{"/app/nope", "/app"} {
|
||||||
res := env.get(path)
|
res := env.get(path)
|
||||||
|
|
||||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает
|
||||||
|
// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же
|
||||||
|
// правило: правило одно, и особого случая у него нет.
|
||||||
|
func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/nope", "/_/", "/%5f/"} {
|
||||||
|
res := env.get(path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, res.Code, path)
|
||||||
|
assert.Contains(t, res.Body.String(), "приложение", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
||||||
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
||||||
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||||
@@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
|||||||
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
||||||
env := setupWebappEnv(t, builtDist(), true)
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
bare := env.get(StorageRoot)
|
bare := env.do(http.MethodGet, AppRoot, true)
|
||||||
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется")
|
||||||
|
assert.NotContains(t, bare.Body.String(), "приложение")
|
||||||
|
|
||||||
|
bareAnonymous := env.get(AppRoot)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code,
|
||||||
|
"голый корень неузнанному отвечает как прочие адреса под ним")
|
||||||
|
assert.NotContains(t, bareAnonymous.Body.String(), "приложение")
|
||||||
|
|
||||||
neighbour := env.get(AppRoot + "le")
|
neighbour := env.get(AppRoot + "le")
|
||||||
assert.Equal(t, http.StatusOK, neighbour.Code)
|
assert.Equal(t, http.StatusOK, neighbour.Code)
|
||||||
@@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) {
|
|||||||
|
|
||||||
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
||||||
// открывшего страницу.
|
// открывшего страницу.
|
||||||
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
assert.Contains(t, env.journal(), "Webapp is not built")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,6 @@ const (
|
|||||||
HaltReasonStuck = "stuck"
|
HaltReasonStuck = "stuck"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
SourceUnknown = "unknown"
|
|
||||||
SourceApi = "api"
|
|
||||||
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
|
|
||||||
// с этим источником не появляется, а константа остаётся: на неё ссылается
|
|
||||||
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
|
|
||||||
SourceTelegram = "telegram"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AudioRecord — аудиозапись, центральная сущность сервиса.
|
// AudioRecord — аудиозапись, центральная сущность сервиса.
|
||||||
//
|
//
|
||||||
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
|
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
|
||||||
@@ -57,7 +48,6 @@ type AudioRecord struct {
|
|||||||
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
||||||
// меняется.
|
// меняется.
|
||||||
OwnerID string
|
OwnerID string
|
||||||
Source string
|
|
||||||
|
|
||||||
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
||||||
// лежат колонками записи, а не строками `texts`.
|
// лежат колонками записи, а не строками `texts`.
|
||||||
@@ -142,7 +132,7 @@ const MaxOriginalFilenameLen = 255
|
|||||||
//
|
//
|
||||||
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
|
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
|
||||||
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
|
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
|
||||||
// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки,
|
// иначе доезжают до экрана; резка идёт **после** уборки,
|
||||||
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
|
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
|
||||||
//
|
//
|
||||||
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
|
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
|
||||||
@@ -162,20 +152,6 @@ func SanitizeOriginalFilename(name string) string {
|
|||||||
return string(runes)
|
return string(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllStates — закрытый перечень рубежей для схемы хранилища.
|
|
||||||
func AllStates() []string {
|
|
||||||
out := make([]string, 0, len(stages))
|
|
||||||
for _, s := range stages {
|
|
||||||
out = append(out, s.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
|
|
||||||
func AllHaltReasons() []string {
|
|
||||||
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
|
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
|
||||||
//
|
//
|
||||||
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
|
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
|
||||||
|
|||||||
+11
-18
@@ -4,21 +4,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
|
|
||||||
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
|
|
||||||
// разным вещам запись о файле и хранилище, в котором она лежит.
|
|
||||||
const (
|
|
||||||
LocationLocal = "local"
|
|
||||||
LocationS3 = "s3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
|
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
|
||||||
// потолка записи в шесть часов с запасом на видео, а не из замера.
|
// потолка записи в шесть часов с запасом на видео, а не из замера.
|
||||||
//
|
//
|
||||||
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
|
// Число называется **явно** везде, где иначе действует умолчание: у тела запроса
|
||||||
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
|
// приёма умолчание отсекало бы запись раньше, чем она дойдёт до обработчика — и
|
||||||
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
|
// без строки в журнале приёма. Умолчания здесь не «без предела», а величины на
|
||||||
// дойдёт до обработчика — без строки в журнале приёма.
|
// два-три порядка меньше нужного.
|
||||||
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||||
|
|
||||||
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
|
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
|
||||||
@@ -26,12 +18,13 @@ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
|||||||
// существует только потому, что провайдер читает аудио по адресу, и её ключ
|
// существует только потому, что провайдер читает аудио по адресу, и её ключ
|
||||||
// живёт в строке попытки распознавания.
|
// живёт в строке попытки распознавания.
|
||||||
type File struct {
|
type File struct {
|
||||||
Id string
|
Id string
|
||||||
Location string
|
// RecordID — запись, которой копия принадлежит. Копии одной записи лежат её
|
||||||
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
|
// подкаталогом, названным этим идентификатором: так они лежат вместе, а
|
||||||
// хранилище к заданному имени не дописывает: суффикс появляется только у
|
// запись убирается целиком одним движением.
|
||||||
// имён, которые оно строит само из имени отправителя, а это умолчание не
|
RecordID string
|
||||||
// применяется.
|
// FileName — имя, под которым файл лежит: имя задаёт сервис. Имя, данное
|
||||||
|
// отправителем, в него не попадает — от него взято только расширение.
|
||||||
FileName string
|
FileName string
|
||||||
Size int64
|
Size int64
|
||||||
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
|
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxProviderLoginLength — предел длины логина у провайдера.
|
||||||
|
//
|
||||||
|
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
|
||||||
|
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
|
||||||
|
// же, что у имени: длиннее имени логин не бывает, а два разных предела на
|
||||||
|
// соседних колонках одной записи разошлись бы молча.
|
||||||
|
const MaxProviderLoginLength = 255
|
||||||
|
|
||||||
|
// MaxDisplayNameLength — предел длины имени, пригодного к показу.
|
||||||
|
const MaxDisplayNameLength = 255
|
||||||
|
|
||||||
|
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
|
||||||
|
// отвергает его.
|
||||||
|
//
|
||||||
|
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
|
||||||
|
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
|
||||||
|
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
|
||||||
|
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
|
||||||
|
//
|
||||||
|
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||||
|
// называет одного человека, а две записи о нём разошлись бы молча. Сравнение при
|
||||||
|
// поиске остаётся точным, знак в знак: приведение регистра завело бы правило,
|
||||||
|
// которого у провайдера нет.
|
||||||
|
//
|
||||||
|
// Живёт в домене, а не в транспорте и не в хранилище: правило одно на все
|
||||||
|
// способы представиться, а уложенное куском в слой оно разошлось бы двумя
|
||||||
|
// копиями при первом же втором способе.
|
||||||
|
func AcceptProviderLogin(value string) (string, bool) {
|
||||||
|
login := strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и два
|
||||||
|
// предела в разных единицах разошлись бы вдвое на любой кириллице.
|
||||||
|
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range login {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return login, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptDisplayName приводит имя к годному для колонки значению.
|
||||||
|
//
|
||||||
|
// Обрезается по пределу и чистится от управляющих знаков — тем же приёмом, каким
|
||||||
|
// приём записи чистит имя файла отправителя. Пустое значение законно: имени у
|
||||||
|
// человека может не быть вовсе, и негодное значение необязательного поля не
|
||||||
|
// вправе отменять заведение учётной записи.
|
||||||
|
func AcceptDisplayName(value string) string {
|
||||||
|
name := strings.TrimSpace(stripControls(value))
|
||||||
|
|
||||||
|
runes := []rune(name)
|
||||||
|
if len(runes) > MaxDisplayNameLength {
|
||||||
|
return string(runes[:MaxDisplayNameLength])
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptEmail отдаёт адрес почты, если он вообще похож на адрес.
|
||||||
|
//
|
||||||
|
// Негодный отбрасывается здесь, а не отказом сохранения: иначе опечатка в
|
||||||
|
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью «почта
|
||||||
|
// занята», и владелец искал бы общий ящик там, где сломан контур.
|
||||||
|
func AcceptEmail(value string) (string, bool) {
|
||||||
|
email := strings.TrimSpace(value)
|
||||||
|
if email == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбор адреса принимает и форму «Имя <адрес>»: такую строку заголовок
|
||||||
|
// приносить не должен, и адресом она не считается.
|
||||||
|
parsed, err := mail.ParseAddress(email)
|
||||||
|
if err != nil || parsed.Name != "" || parsed.Address != email {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
|
||||||
|
// им делать нечего.
|
||||||
|
func stripControls(value string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
@@ -87,10 +87,11 @@ func (r *RecognitionResult) GetError() string {
|
|||||||
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
|
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
|
||||||
// сущность вовсе.
|
// сущность вовсе.
|
||||||
//
|
//
|
||||||
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает
|
// Сохранённый ответ провайдера лежит **третьим файлом в подкаталоге записи**, а
|
||||||
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
// не колонкой этой строки: шаг опроса читает её раз в несколько секунд, а
|
||||||
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому,
|
// репозиторий читает строку целиком — ответ на многочасовую запись ехал бы в
|
||||||
// что результат операции у провайдера не переспрашивается.
|
// память при каждом опросе. Хранится он потому, что результат операции у
|
||||||
|
// провайдера не переспрашивается.
|
||||||
type Recognition struct {
|
type Recognition struct {
|
||||||
Id string
|
Id string
|
||||||
RecordID string
|
RecordID string
|
||||||
@@ -115,6 +116,6 @@ type RecognitionOutcome struct {
|
|||||||
Replicas []Replica
|
Replicas []Replica
|
||||||
// PlainText — плоский текст расшифровки.
|
// PlainText — плоский текст расшифровки.
|
||||||
PlainText string
|
PlainText string
|
||||||
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением.
|
// Raw — ответ провайдера целиком, как он пришёл, на хранение отдельным файлом.
|
||||||
Raw []byte
|
Raw []byte
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,6 @@ const (
|
|||||||
EventOutcomeResumed = "resumed"
|
EventOutcomeResumed = "resumed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllEventOrigins — закрытый перечень источников для схемы хранилища.
|
|
||||||
func AllEventOrigins() []string {
|
|
||||||
return []string{EventOriginPipeline, EventOriginHuman}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllEventOutcomes — закрытый перечень исходов для схемы хранилища.
|
|
||||||
func AllEventOutcomes() []string {
|
|
||||||
return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordEvent — строка журнала событий одной записи.
|
// RecordEvent — строка журнала событий одной записи.
|
||||||
//
|
//
|
||||||
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
|
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package entity
|
|
||||||
|
|
||||||
// Состояния прежней модели. **Частью модели они не являются** и в перечень
|
|
||||||
// рубежей не входят: цепочку рубежей объявляет `stage.go`, а закрытый перечень
|
|
||||||
// для схемы — `AllStates()`.
|
|
||||||
//
|
|
||||||
// Живут они здесь по одной причине: шаг схемы `202608110001_init.go` заводил
|
|
||||||
// прежнюю коллекцию задач этими значениями, а **применённый шаг схемы не
|
|
||||||
// переписывается** — хранилище считает применённое по имени файла, и правка
|
|
||||||
// сделала бы его другим шагом под прежним именем. Шаг ссылается на эти
|
|
||||||
// константы, значит они обязаны существовать, пока существует он.
|
|
||||||
//
|
|
||||||
// Коллекцию, которую он заводил, удаляет шаг `202608140002`. Ни один живой путь
|
|
||||||
// сервиса этих значений не читает и не пишет; `StateDone` в этом списке нет —
|
|
||||||
// то же слово осталось именем конечного рубежа новой модели.
|
|
||||||
const (
|
|
||||||
StateCreated = "created"
|
|
||||||
StateConverted = "converted"
|
|
||||||
StateTranscribe = "transcribe"
|
|
||||||
StateFailed = "failed"
|
|
||||||
StateDead = "dead"
|
|
||||||
)
|
|
||||||
@@ -42,11 +42,6 @@ func IsKnownTextView(view string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
|
|
||||||
func AllTextKinds() []string {
|
|
||||||
return []string{TextKindTranscript, TextKindLiterary}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
|
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
|
||||||
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
||||||
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Package ident — единая точка выдачи идентификаторов строк.
|
||||||
|
//
|
||||||
|
// Идентификатор — ULID в нижнем регистре: 48 бит времени в миллисекундах плюс
|
||||||
|
// 80 бит случайности, записанные алфавитом Crockford base32. Так требует
|
||||||
|
// конвенция проекта [docs/conventions/database.md]; прежде идентификаторы
|
||||||
|
// выдавало встроенное хранилище своим алфавитом, и точки выдачи у приложения не
|
||||||
|
// было вовсе.
|
||||||
|
//
|
||||||
|
// Почему ULID, а не UUID: ширина записи постоянная, а старшие разряды несут
|
||||||
|
// время — лента записей упорядочивается парой «время заведения и ключ», и ключ
|
||||||
|
// в этой паре не спорит с временем, а продолжает его.
|
||||||
|
//
|
||||||
|
// Регистр нижний, и это тоже правило конвенции: сравнение строк в SQLite
|
||||||
|
// побайтово, поэтому канонический вид обязан быть один. Пришедший снаружи
|
||||||
|
// идентификатор приводится к нему разбором на границе — Parse.
|
||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alphabet — Crockford base32 в нижнем регистре. Из него исключены `i`, `l`,
|
||||||
|
// `o` и `u`: первые три неотличимы от цифр в наборах без засечек, последняя
|
||||||
|
// исключена, чтобы случайная строка не складывалась в бранное слово.
|
||||||
|
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||||
|
|
||||||
|
// Len — длина записи ULID: 10 знаков времени и 16 знаков случайности.
|
||||||
|
const Len = 26
|
||||||
|
|
||||||
|
// decode — обратная таблица алфавита. Заполняется один раз: разбор идёт на
|
||||||
|
// каждом запросе с идентификатором в пути, и собирать таблицу по месту значило
|
||||||
|
// бы платить за неё столько же раз.
|
||||||
|
var decode = func() [256]int8 {
|
||||||
|
var table [256]int8
|
||||||
|
for i := range table {
|
||||||
|
table[i] = -1
|
||||||
|
}
|
||||||
|
for i, r := range alphabet {
|
||||||
|
table[byte(r)] = int8(i)
|
||||||
|
// Заглавный знак принимается разбором наравне со строчным: алфавит
|
||||||
|
// Crockford к регистру нечувствителен, и человек, скопировавший
|
||||||
|
// идентификатор из чужого письма, не обязан знать про канонический вид.
|
||||||
|
table[byte(strings.ToUpper(string(r))[0])] = int8(i)
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Состояние выдачи: последняя метка времени и последняя случайная часть.
|
||||||
|
//
|
||||||
|
// Нужно ради **монотонности внутри миллисекунды**. Колонка времени несёт
|
||||||
|
// секунды, и порядок записей, заведённых в одну секунду, задаёт ключ: случайная
|
||||||
|
// часть, выданная заново, поставила бы их в произвольном порядке — «новые
|
||||||
|
// сверху» стало бы «как повезёт», а страница ленты читалась бы через раз.
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
lastMs uint64
|
||||||
|
lastRand [10]byte
|
||||||
|
)
|
||||||
|
|
||||||
|
// New выдаёт новый идентификатор.
|
||||||
|
//
|
||||||
|
// Время берётся единой точкой чтения времени, а не `time.Now`: запрет держит
|
||||||
|
// линтер, и обойти его здесь значило бы завести вторые часы у ключей.
|
||||||
|
//
|
||||||
|
// Случайность берётся у `crypto/rand`. Он не отказывает: с Go 1.24 чтение из
|
||||||
|
// него не возвращает ошибки вовсе, а невозможность получить случайность — отказ
|
||||||
|
// такого рода, из которого не стартуют.
|
||||||
|
//
|
||||||
|
// В пределах одной миллисекунды случайная часть **растёт на единицу**, а не
|
||||||
|
// выдаётся заново: два идентификатора одной миллисекунды обязаны идти в порядке
|
||||||
|
// выдачи. Переполнение прибавляет миллисекунду — исход недостижимый на любой
|
||||||
|
// мыслимой нагрузке, но названный, потому что молчаливый откат назад испортил бы
|
||||||
|
// порядок сильнее любой случайности.
|
||||||
|
func New() string {
|
||||||
|
ms := uint64(clock.Now().UnixMilli())
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
switch {
|
||||||
|
case ms > lastMs:
|
||||||
|
lastMs = ms
|
||||||
|
// Ошибку `rand.Read` не проверяем сознательно: с Go 1.24 он её не
|
||||||
|
// возвращает, а `errcheck` довольствуется явным присваиванием в
|
||||||
|
// пустышку.
|
||||||
|
_, _ = rand.Read(lastRand[:])
|
||||||
|
default:
|
||||||
|
// Часы могли и отступить назад: метка тогда остаётся прежней, а порядок
|
||||||
|
// держит растущая случайная часть.
|
||||||
|
if !increment(&lastRand) {
|
||||||
|
lastMs++
|
||||||
|
_, _ = rand.Read(lastRand[:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ms, entropy := lastMs, lastRand
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
var raw [16]byte
|
||||||
|
raw[0] = byte(ms >> 40)
|
||||||
|
raw[1] = byte(ms >> 32)
|
||||||
|
raw[2] = byte(ms >> 24)
|
||||||
|
raw[3] = byte(ms >> 16)
|
||||||
|
raw[4] = byte(ms >> 8)
|
||||||
|
raw[5] = byte(ms)
|
||||||
|
copy(raw[6:], entropy[:])
|
||||||
|
|
||||||
|
return encode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// increment прибавляет единицу к случайной части. Ложь означает переполнение —
|
||||||
|
// все восемьдесят разрядов были заняты.
|
||||||
|
func increment(value *[10]byte) bool {
|
||||||
|
for i := len(value) - 1; i >= 0; i-- {
|
||||||
|
value[i]++
|
||||||
|
if value[i] != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode переводит шестнадцать байт в двадцать шесть знаков алфавита.
|
||||||
|
//
|
||||||
|
// Разрядов у записи 130, а байт — 128, поэтому старший знак несёт только два
|
||||||
|
// младших бита первого байта; остальные знаки идут ровными пятёрками бит.
|
||||||
|
func encode(raw [16]byte) string {
|
||||||
|
var n [130]byte
|
||||||
|
for i := range 128 {
|
||||||
|
n[i+2] = raw[i/8] >> (7 - i%8) & 1
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, Len)
|
||||||
|
for i := range out {
|
||||||
|
var v byte
|
||||||
|
for j := range 5 {
|
||||||
|
v = v<<1 | n[i*5+j]
|
||||||
|
}
|
||||||
|
out[i] = alphabet[v]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse разбирает идентификатор, пришедший снаружи: проверяет вид и приводит
|
||||||
|
// регистр. Второе значение ложно у всего, что видом не совпало.
|
||||||
|
//
|
||||||
|
// Разбор стоит на границе, а не в запросе к базе: строка приходит от
|
||||||
|
// спрашивающего, а сравнение в базе побайтово — идентификатор в верхнем
|
||||||
|
// регистре не совпал бы ни с одной строкой, и «моя запись пропала» читалось бы
|
||||||
|
// как отказ разграничения.
|
||||||
|
func Parse(value string) (string, bool) {
|
||||||
|
if len(value) != Len {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, Len)
|
||||||
|
for i := range Len {
|
||||||
|
v := decode[value[i]]
|
||||||
|
if v < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
out[i] = alphabet[v]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старший знак несёт всего два бита времени: запись, у которой он больше
|
||||||
|
// семёрки, описывает время за пределами разрядности и годным ULID не
|
||||||
|
// является.
|
||||||
|
if decode[out[0]] > 7 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(out), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp отдаёт время, зашитое в идентификатор. Нужен проверкам: по нему
|
||||||
|
// видно, что ключ и колонка времени идут в одну сторону.
|
||||||
|
func Timestamp(id string) (time.Time, bool) {
|
||||||
|
parsed, ok := Parse(id)
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ms uint64
|
||||||
|
for i := range 10 {
|
||||||
|
ms = ms<<5 | uint64(decode[parsed[i]])
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.UnixMilli(int64(ms)).UTC(), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Идентификатор — ULID в нижнем регистре: постоянная ширина, алфавит Crockford,
|
||||||
|
// и разбор своего же значения его не меняет.
|
||||||
|
func TestNewIsCanonical(t *testing.T) {
|
||||||
|
id := New()
|
||||||
|
|
||||||
|
assert.Len(t, id, Len)
|
||||||
|
assert.Equal(t, strings.ToLower(id), id, "идентификатор выдан не в нижнем регистре")
|
||||||
|
|
||||||
|
parsed, ok := Parse(id)
|
||||||
|
require.True(t, ok, "свой же идентификатор не разобрался")
|
||||||
|
assert.Equal(t, id, parsed, "разбор изменил канонический вид")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок выдачи и порядок сравнения строк совпадают — в том числе внутри одной
|
||||||
|
// миллисекунды: колонка времени несёт секунды, и «новые сверху» держит ключ.
|
||||||
|
func TestNewIsMonotonic(t *testing.T) {
|
||||||
|
previous := New()
|
||||||
|
for range 10000 {
|
||||||
|
next := New()
|
||||||
|
require.Less(t, previous, next, "идентификатор выдан не по возрастанию")
|
||||||
|
previous = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выдача идёт из нескольких потоков разом: два одинаковых ключа означали бы
|
||||||
|
// отказ вставки на живой базе.
|
||||||
|
func TestNewIsUniqueUnderConcurrency(t *testing.T) {
|
||||||
|
const goroutines = 8
|
||||||
|
const each = 500
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
all = map[string]bool{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range goroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
own := make([]string, 0, each)
|
||||||
|
for range each {
|
||||||
|
own = append(own, New())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, id := range own {
|
||||||
|
all[id] = true
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Len(t, all, goroutines*each, "выданы одинаковые идентификаторы")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбор проверяет вид и приводит регистр: значение приходит от спрашивающего, а
|
||||||
|
// сравнение в базе побайтово.
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
id := New()
|
||||||
|
|
||||||
|
upper, ok := Parse(strings.ToUpper(id))
|
||||||
|
require.True(t, ok, "верхний регистр не принят: алфавит к нему нечувствителен")
|
||||||
|
assert.Equal(t, id, upper, "разбор не привёл регистр к каноническому")
|
||||||
|
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"пусто": "",
|
||||||
|
"короче": id[:Len-1],
|
||||||
|
"длиннее": id + "0",
|
||||||
|
"знак вне алфавита": strings.Repeat("u", Len),
|
||||||
|
"время за пределом": "8" + id[1:],
|
||||||
|
"не латиница": strings.Repeat("я", Len),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, ok := Parse(value)
|
||||||
|
assert.False(t, ok, "негодное значение принято разбором")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Время, зашитое в идентификатор, идёт в ту же сторону, что и колонка времени.
|
||||||
|
func TestTimestampGrowsWithIssue(t *testing.T) {
|
||||||
|
first, ok := Timestamp(New())
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
second, ok := Timestamp(New())
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
assert.False(t, second.Before(first), "время в ключе пошло назад")
|
||||||
|
}
|
||||||
@@ -18,11 +18,11 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
|||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
first, err := env.service.CreateJobFromApi(t.Context(),
|
first, err := env.service.CreateJobFromApi(t.Context(),
|
||||||
strings.NewReader("первая"), "one.mp3", newOwner(t, env.app))
|
strings.NewReader("первая"), "one.mp3", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
second, err := env.service.CreateJobFromApi(t.Context(),
|
second, err := env.service.CreateJobFromApi(t.Context(),
|
||||||
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
|
strings.NewReader("вторая"), "two.mp3", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Третья — ещё одного владельца: воркер не сужается ни одним из них.
|
// Третья — ещё одного владельца: воркер не сужается ни одним из них.
|
||||||
@@ -52,7 +52,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
|||||||
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
owner := newOwner(t, env.app)
|
owner := newOwner(t, env)
|
||||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
|||||||
func TestPipelineStepKeepsOwner(t *testing.T) {
|
func TestPipelineStepKeepsOwner(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
owner := newOwner(t, env.app)
|
owner := newOwner(t, env)
|
||||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -102,8 +102,8 @@ func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
|||||||
func TestGetByIDHidesForeignRecords(t *testing.T) {
|
func TestGetByIDHidesForeignRecords(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
owner := newOwner(t, env.app)
|
owner := newOwner(t, env)
|
||||||
stranger := newOwner(t, env.app)
|
stranger := newOwner(t, env)
|
||||||
|
|
||||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// timeLayout — вид времени в колонках базы. Проверки, двигающие сроки, пишут его
|
||||||
|
// тем же видом, каким пишет хранилище: сравнение там побайтово.
|
||||||
|
const timeLayout = "2006-01-02T15:04:05Z"
|
||||||
|
|
||||||
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и
|
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и
|
||||||
// остановка держатся на запросе, и подставной репозиторий проверял бы
|
// остановка держатся на запросе, и подставной репозиторий проверял бы
|
||||||
// собственную заглушку, а не то, что делает база.
|
// собственную заглушку, а не то, что делает база.
|
||||||
@@ -61,11 +63,13 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pipelineEnv struct {
|
type pipelineEnv struct {
|
||||||
app core.App
|
db *sqliterepo.DB
|
||||||
|
store *sqliterepo.Store
|
||||||
|
users *sqliterepo.UserRepository
|
||||||
service *TranscribeService
|
service *TranscribeService
|
||||||
repos Repositories
|
repos Repositories
|
||||||
recordRepo *pbrepo.AudioRecordRepository
|
recordRepo *sqliterepo.AudioRecordRepository
|
||||||
fileRepo *pbrepo.FileRepository
|
fileRepo *sqliterepo.FileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
|
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
|
||||||
@@ -98,33 +102,34 @@ func newPipelineEnvWithLogger(
|
|||||||
) *pipelineEnv {
|
) *pipelineEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app, err := pbrepo.New(t.TempDir())
|
dir := t.TempDir()
|
||||||
|
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
if err := app.ResetBootstrapState(); err != nil {
|
if err := db.Close(); err != nil {
|
||||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
t.Logf("не удалось закрыть базу: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Правила панели вешаются и здесь: конфигурация под проверкой обязана
|
require.NoError(t, sqliterepo.Migrate(t.Context(), db, dir, slog.New(slog.DiscardHandler)))
|
||||||
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
|
|
||||||
// нет.
|
|
||||||
pbrepo.BindPanelRules(app)
|
|
||||||
|
|
||||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
store := sqliterepo.NewStore(dir)
|
||||||
fileRepo := pbrepo.NewFileRepository(app)
|
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||||
|
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||||
repos := Repositories{
|
repos := Repositories{
|
||||||
Records: recordRepo,
|
Records: recordRepo,
|
||||||
Files: fileRepo,
|
Files: fileRepo,
|
||||||
Texts: pbrepo.NewTextRepository(app),
|
Texts: sqliterepo.NewTextRepository(db),
|
||||||
Structures: pbrepo.NewStructureRepository(app),
|
Structures: sqliterepo.NewStructureRepository(db),
|
||||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||||
Events: pbrepo.NewRecordEventRepository(app),
|
Events: sqliterepo.NewRecordEventRepository(db),
|
||||||
}
|
}
|
||||||
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
|
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
|
||||||
|
|
||||||
return &pipelineEnv{
|
return &pipelineEnv{
|
||||||
app: app,
|
db: db,
|
||||||
|
store: store,
|
||||||
|
users: sqliterepo.NewUserRepository(db),
|
||||||
service: svc,
|
service: svc,
|
||||||
repos: repos,
|
repos: repos,
|
||||||
recordRepo: recordRepo,
|
recordRepo: recordRepo,
|
||||||
@@ -132,13 +137,22 @@ func newPipelineEnvWithLogger(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exec выполняет запрос к базе от имени проверки: фикстуры двигают колонки
|
||||||
|
// напрямую там, где домен такого перехода не делает.
|
||||||
|
func (e *pipelineEnv) exec(t *testing.T, query string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := e.db.Writer().ExecContext(context.Background(), query, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// newRecord заводит запись — так, как её заводит приём по HTTP: от имени
|
// newRecord заводит запись — так, как её заводит приём по HTTP: от имени
|
||||||
// вошедшего, потому что ничьей записи в хранилище не бывает.
|
// вошедшего, потому что ничьей записи в хранилище не бывает.
|
||||||
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.service.CreateJobFromApi(
|
record, err := env.service.CreateJobFromApi(
|
||||||
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
@@ -147,10 +161,7 @@ func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
|||||||
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
env.exec(t, "UPDATE audio_records SET delay_time = NULL WHERE id = ?", recordID)
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("delay_time", "")
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
|
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
|
||||||
@@ -158,13 +169,8 @@ func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
|||||||
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
|
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
env.exec(t, "UPDATE audio_records SET state_entered_at = ? WHERE id = ?",
|
||||||
require.NoError(t, err)
|
moment.UTC().Format(timeLayout), recordID)
|
||||||
record.Set("state_entered_at", types.DateTime{}.Add(0))
|
|
||||||
stamp, err := types.ParseDateTime(moment)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("state_entered_at", stamp)
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
|
// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
|
||||||
@@ -172,10 +178,7 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time
|
|||||||
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
|
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
env.exec(t, "UPDATE audio_records SET attempts = ? WHERE id = ?", attempts, recordID)
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("attempts", attempts)
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
||||||
@@ -253,10 +256,8 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
|||||||
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
env.exec(t, "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
|
||||||
require.NoError(t, err)
|
clock.Now().Add(-time.Hour).Format(timeLayout), recordID)
|
||||||
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
|
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
|
||||||
@@ -508,11 +509,10 @@ func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
|
|||||||
events := recordEvents(t, env, record.Id)
|
events := recordEvents(t, env, record.Id)
|
||||||
require.Len(t, events, 1, "остановка оставила строку журнала событий")
|
require.Len(t, events, 1, "остановка оставила строку журнала событий")
|
||||||
|
|
||||||
outcome := events[0].GetString("outcome")
|
|
||||||
assert.Contains(t,
|
assert.Contains(t,
|
||||||
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome,
|
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, events[0].Outcome,
|
||||||
"строка журнала называет исход остановкой")
|
"строка журнала называет исход остановкой")
|
||||||
assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину")
|
assert.NotEmpty(t, events[0].OutcomeText, "и несёт причину")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,9 +536,9 @@ func TestRepeatedStoreKeepsSingleText(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "второй разбор", stored.Contents)
|
assert.Equal(t, "второй разбор", stored.Contents)
|
||||||
|
|
||||||
count, err := env.app.CountRecords(migrations.TextsCollection)
|
var count int
|
||||||
require.NoError(t, err)
|
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
|
||||||
assert.Equal(t, int64(1), count, "второго комплекта строк не завелось")
|
assert.Equal(t, 1, count, "второго комплекта строк не завелось")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пауза растёт с числом отказов и упирается в потолок.
|
// Пауза растёт с числом отказов и упирается в потолок.
|
||||||
@@ -559,19 +559,12 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
|||||||
|
|
||||||
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
|
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
|
||||||
// получении рабочей копии — то есть отказом, а не приговором записи.
|
// получении рабочей копии — то есть отказом, а не приговором записи.
|
||||||
files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection)
|
emptyID := ident.New()
|
||||||
require.NoError(t, err)
|
|
||||||
empty := core.NewRecord(files)
|
|
||||||
empty.Set("location", entity.LocationLocal)
|
|
||||||
empty.Set("size", 1)
|
|
||||||
// Владелец обязателен и у файла: схема ничьих не принимает.
|
// Владелец обязателен и у файла: схема ничьих не принимает.
|
||||||
empty.Set("owner", record.OwnerID)
|
env.exec(t, `INSERT INTO files (id, owner_id, record_id, file_name, size_bytes, created_at)
|
||||||
require.NoError(t, env.app.Save(empty))
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
emptyID, record.OwnerID, record.Id, "missing.mp3", 1, clock.Now().Format(timeLayout))
|
||||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
env.exec(t, "UPDATE audio_records SET original_file_id = ? WHERE id = ?", emptyID, record.Id)
|
||||||
require.NoError(t, err)
|
|
||||||
stored.Set("original_file", empty.Id)
|
|
||||||
require.NoError(t, env.app.Save(stored))
|
|
||||||
|
|
||||||
require.Error(t, env.service.RunStep(t.Context()))
|
require.Error(t, env.service.RunStep(t.Context()))
|
||||||
|
|
||||||
@@ -596,7 +589,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
|
||||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
@@ -612,7 +605,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
@@ -666,7 +659,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
content := strings.Repeat("запись ", 1000)
|
content := strings.Repeat("запись ", 1000)
|
||||||
|
|
||||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, record.OriginalFileID)
|
require.NotNil(t, record.OriginalFileID)
|
||||||
|
|
||||||
@@ -689,7 +682,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, record.OriginalFileID)
|
require.NotNil(t, record.OriginalFileID)
|
||||||
|
|
||||||
@@ -707,24 +700,17 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
|
|||||||
|
|
||||||
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||||
//
|
//
|
||||||
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
// Владелец — связь с таблицей учётных записей, и база проверяет, что такая
|
||||||
// запись есть: выдуманный идентификатор запись завести не даст.
|
// строка есть: выдуманный идентификатор запись завести не даст.
|
||||||
func newOwner(t *testing.T, app core.App) string {
|
func newOwner(t *testing.T, env *pipelineEnv) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
|
||||||
|
// одним ключом схема не примет.
|
||||||
|
account, _, err := env.users.EnsureUser(contract.Identity{Login: ident.New()})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record := core.NewRecord(users)
|
return account.ID
|
||||||
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
|
|
||||||
// пустым ключом схема не примет.
|
|
||||||
record.Set(migrations.ProviderLoginField, uuid.NewString())
|
|
||||||
record.Set("email", uuid.NewString()+"@example.test")
|
|
||||||
record.Set("verified", true)
|
|
||||||
record.Set("password", uuid.NewString())
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
return record.Id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
|
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
|
||||||
@@ -752,7 +738,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
|
|||||||
|
|
||||||
events := recordEvents(t, env, record.Id)
|
events := recordEvents(t, env, record.Id)
|
||||||
require.Len(t, events, 1, "одна строка журнала, а не две")
|
require.Len(t, events, 1, "одна строка журнала, а не две")
|
||||||
assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"),
|
assert.Equal(t, entity.EventOutcomeFailed, events[0].Outcome,
|
||||||
"исход назван приговором, а не сделанной работой")
|
"исход назван приговором, а не сделанной работой")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,18 +766,32 @@ func TestPostponeWritesNoEvent(t *testing.T) {
|
|||||||
"пять откладываний не оставили в журнале ни строки")
|
"пять откладываний не оставили в журнале ни строки")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordEventRow — строка журнала событий записи, какой её видит проверка.
|
||||||
|
type recordEventRow struct {
|
||||||
|
Origin string
|
||||||
|
Step string
|
||||||
|
Outcome string
|
||||||
|
OutcomeText string
|
||||||
|
}
|
||||||
|
|
||||||
// recordEvents читает журнал событий одной записи в порядке заведения.
|
// recordEvents читает журнал событий одной записи в порядке заведения.
|
||||||
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record {
|
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []recordEventRow {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
all, err := env.app.FindAllRecords(migrations.RecordEventsCollection)
|
rows, err := env.db.Reader().QueryContext(context.Background(),
|
||||||
|
"SELECT origin, step, outcome, outcome_text FROM record_events WHERE record_id = ? ORDER BY id",
|
||||||
|
recordID,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, rows.Close()) }()
|
||||||
|
|
||||||
var own []*core.Record
|
var own []recordEventRow
|
||||||
for _, event := range all {
|
for rows.Next() {
|
||||||
if event.GetString("record") == recordID {
|
var event recordEventRow
|
||||||
own = append(own, event)
|
require.NoError(t, rows.Scan(&event.Origin, &event.Step, &event.Outcome, &event.OutcomeText))
|
||||||
}
|
own = append(own, event)
|
||||||
}
|
}
|
||||||
|
require.NoError(t, rows.Err())
|
||||||
|
|
||||||
return own
|
return own
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,9 +154,12 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
|
|||||||
return nil, contract.ErrOwnerRequired
|
return nil, contract.ErrOwnerRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Идентификатор назначается здесь, до укладки файла: копии записи лежат её
|
||||||
|
// подкаталогом, названным этим идентификатором, и знать его надо раньше, чем
|
||||||
|
// класть первую копию.
|
||||||
record := &entity.AudioRecord{
|
record := &entity.AudioRecord{
|
||||||
|
Id: ident.New(),
|
||||||
State: entity.StateUploaded,
|
State: entity.StateUploaded,
|
||||||
Source: entity.SourceApi,
|
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +178,10 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
|||||||
ext = fmt.Sprintf(".%s", defaultAudioExt)
|
ext = fmt.Sprintf(".%s", defaultAudioExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собственное имя записи: идентификатор с расширением. Имя, данное
|
// Собственное имя копии: идентификатор с расширением. Имя, данное
|
||||||
// отправителем, в хранилище не попадает — от него взято только расширение.
|
// отправителем, в каталог данных не попадает — от него взято только
|
||||||
fileId := uuid.NewString()
|
// расширение.
|
||||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
storageFileName := fmt.Sprintf("%s%s", ident.New(), ext)
|
||||||
|
|
||||||
// Содержимое ложится в рабочую копию потоком: в память запись целиком не
|
// Содержимое ложится в рабочую копию потоком: в память запись целиком не
|
||||||
// читается, расчётный потолок — шесть часов.
|
// читается, расчётный потолок — шесть часов.
|
||||||
@@ -217,7 +219,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
|||||||
DurationMs: int64(info.Seconds) * 1000,
|
DurationMs: int64(info.Seconds) * 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID)
|
fileRecord, err := s.repos.Files.Create(r.Id, storageFileName, work, meta, r.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -474,9 +476,9 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
|||||||
|
|
||||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
||||||
|
|
||||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
destFileName := fmt.Sprintf("%s%s", ident.New(), ".ogg")
|
||||||
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
|
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
|
||||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID)
|
destFileRecord, err := s.repos.Files.Create(r.Id, destFileName, dest, destMeta, r.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
|
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
|
||||||
return outcomeDone, err
|
return outcomeDone, err
|
||||||
@@ -780,8 +782,8 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
|
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
|
||||||
// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом
|
// ответа отправителю у сервиса нет, и свой исход владелец записи узнаёт её
|
||||||
// готовности.
|
// карточкой.
|
||||||
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||||
r.MoveToState(entity.StateDone)
|
r.MoveToState(entity.StateDone)
|
||||||
if err := s.repos.Records.Save(r, holder); err != nil {
|
if err := s.repos.Records.Save(r, holder); err != nil {
|
||||||
@@ -806,7 +808,7 @@ func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string,
|
|||||||
// событий.
|
// событий.
|
||||||
//
|
//
|
||||||
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
|
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
|
||||||
// теряется молча» держится теперь опросом готовности — остановка видна там
|
// теряется молча» держится теперь карточкой записи — остановка видна там
|
||||||
// признаком — и журналом владельца, где у неё стоит причина.
|
// признаком — и журналом владельца, где у неё стоит причина.
|
||||||
//
|
//
|
||||||
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
|
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-22
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user