хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+47 -8
View File
@@ -2,6 +2,7 @@ package contract
import (
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -40,19 +41,24 @@ type FileRepository interface {
StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error)
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// отправителя, не применяется.
// Create кладёт рабочую копию в каталог данных под именем name и заводит
// строку о файле. Имя задаёт сервис, и имя, данное отправителем, в него не
// попадает: от него взято только расширение.
//
// recordID — запись, которой копия принадлежит: копии одной записи лежат её
// подкаталогом, и имя этого подкаталога и есть идентификатор записи. Приём
// знает его раньше, чем кладёт файл, потому что назначает сам.
//
// 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)
// Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error)
// Open отдаёт содержимое хранимого файла потоком с перемоткой: отдача по
// диапазону читает запрошенный кусок, а не файл целиком.
Open(fileID string) (io.ReadSeekCloser, error)
}
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
@@ -74,8 +80,12 @@ type AcquiredRecord struct {
// RecordCursor — положение в ленте записей, заданное **полным** ключом
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
// совпадает, и порядок между ними иначе не определён.
//
// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в
// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный
// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь.
type RecordCursor struct {
CreatedAt string
CreatedAt time.Time
ID string
}
@@ -154,7 +164,8 @@ type RecognitionRepository interface {
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
Submitted(id, sourceURI, externalID string) error
// Finish отмечает завершение операции и кладёт сырой ответ вложением.
// Finish отмечает завершение операции и кладёт сохранённый ответ отдельным
// файлом в подкаталоге записи.
Finish(id string, raw []byte) error
GetByID(id string) (*entity.Recognition, error)
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
@@ -166,3 +177,31 @@ type RecognitionRepository interface {
type RecordEventRepository interface {
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)
}