хранилище переехало с 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
+272 -167
View File
@@ -11,25 +11,18 @@ import (
"strings"
"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/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
)
// AppRoot — корень адресов приложения.
//
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
// нашим, и разойдутся они молча.
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
const AppRoot = "/app"
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
@@ -55,17 +48,23 @@ const pollBudgetShare = 8
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
// которого сервис сам же ему обещал избежать.
//
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
// выполняется с запасом.
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
// публичным контрактом молча.
const (
CopyParam = "copy"
CopyOriginal = "original"
CopyNormalized = "normalized"
)
type AppHandler struct {
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
structureRepo contract.StructureRepository
fileRepo contract.FileRepository
trsService *service.TranscribeService
logger *slog.Logger
}
@@ -74,6 +73,7 @@ func NewAppHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
structureRepo contract.StructureRepository,
fileRepo contract.FileRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *AppHandler {
@@ -84,6 +84,7 @@ func NewAppHandler(
recordRepo: recordRepo,
textRepo: textRepo,
structureRepo: structureRepo,
fileRepo: fileRepo,
trsService: trsService,
logger: logger,
}
@@ -95,8 +96,11 @@ func NewAppHandler(
//
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
// Причина остановки — значение из закрытого перечня, и она не он: без причины
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
// признак остановки не говорит человеку, чего ждать.
//
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
// выводится из рубежа записи, который карточка несёт и так. Второе поле
// повторяло бы рубеж и разошлось бы с ним молча.
type RecordView struct {
ID string `json:"id"`
Title *string `json:"title"`
@@ -120,11 +124,6 @@ type RecordView struct {
}
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
//
// Место под признак заведено вперёд и заполняется другой задачей. Форма
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
// форму.
type IntakeItem struct {
RecordView
Duplicate bool `json:"duplicate"`
@@ -161,68 +160,134 @@ type ReplicaView struct {
Text string `json:"text"`
}
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один.
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
app := r.Group(AppRoot)
// Routes — адреса приложения одним обработчиком.
//
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
// адресного пространства. Так область их действия выводится из объявленного
// пространства, а не перечисляется вторым списком.
//
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
// приложения одна.
func (h *AppHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
// иначе ушли бы телом библиотеки, мимо единой формы.
//
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
// адрес выдачи файлового токена из пространства хранилища. Область его
// действия при этом выводится из **этого же** перечня адресного
// пространства — см. `underIdentifiedArea`.
app.Bind(OneErrorForm())
app.Bind(RequireUser(migrations.UsersCollection))
for _, pattern := range AppRoutePatterns {
mux.HandleFunc(pattern, h.handlerOf(pattern))
}
app.GET("/me", h.Me)
app.GET("/config", h.Config)
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
// же: он принадлежит корню приложения, адресом приложения не является и
// потому отвечает как неизвестный путь под ним.
//
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
// закрытый перечень образцов они не входят: под них подходит **всё**, что
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
mux.HandleFunc(AppRoot+"/", h.notFound)
mux.HandleFunc(AppRoot, h.notFound)
// Приём стоит тем же адресом, что и список, и отличается только методом: он
// заводит аудиозапись, а не кладёт файл.
//
// Умолчание роутера хранилища — 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, "Адрес не найден"))
})
return mux
}
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{
ID: e.Auth.Id,
Name: e.Auth.GetString("name"),
})
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.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,
MaxPageSize: MaxPageLimit,
PollIntervalMs: PollIntervalMs,
@@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error {
})
}
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
file, header, err := e.Request.FormFile("audio")
func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
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 errors.Is(err, apis.ErrRequestEntityTooLarge) {
return fail(e, contract.ErrRecordTooLarge)
// Предел тела ловит запись на чтении. Не различив этот отказ и
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
// записи, которую он приложил и которая просто больше потолка.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
fail(w, contract.ErrRecordTooLarge)
return
}
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
return
}
defer func() {
if err := file.Close(); err != nil {
@@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — карточкой записи. Значения контекста (журнал запроса,
// сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
// может и позже — карточкой записи.
ctx := context.WithoutCancel(r.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 {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
// делает это одним местом — по причине отказа, а не по месту.
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{}),
}})
}
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
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)
if err != nil || limit <= 0 {
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
return
}
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
// сервис отдаёт, но просьба сама по себе не негодна.
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)
if !ok {
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
return
}
q.Filter = &filter
}
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
if raw := r.URL.Query().Get("cursor"); raw != "" {
cursor, err := decodeCursor(raw)
if err != nil {
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
// архив, листающийся по кругу, и ни строки в журнале.
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
return
}
q.Cursor = cursor
}
page, err := h.recordRepo.List(q)
if err != nil {
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
return fail(e, err)
h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
fail(w, err)
return
}
names, err := h.topicNames(e.Auth.Id, page.Items)
names, err := h.topicNames(account.ID, page.Items)
if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
return fail(e, err)
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
fail(w, err)
return
}
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
}
return e.JSON(http.StatusOK, view)
writeJSON(w, http.StatusOK, view)
}
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
record, err := h.readOwn(e)
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
record, err := h.readOwn(r, account.ID)
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 {
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)
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 {
view := e.Request.URL.Query().Get("view")
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
view := r.URL.Query().Get("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 {
return fail(e, err)
fail(w, err)
return
}
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 читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
// одним и тем же: по разнице ответов иначе перебирается список заведённых
// записей.
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
recordID := e.Request.PathValue("id")
// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
// иначе перебирается список заведённых записей.
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
// Идентификатор разбирается на границе: он приходит от спрашивающего, а
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
// одной строкой. Негодный по виду считается несуществующим и до базы не
// доходит вовсе.
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 {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда.
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
// узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
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
}
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
if view == entity.TextViewLiterary {
textID = record.LiteraryTextID
}
if textID == nil {
return fail(e, contract.ErrTextNotReady)
fail(w, contract.ErrTextNotReady)
return
}
text, err := h.textRepo.GetByID(*textID)
if err != nil {
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
return fail(e, err)
fail(w, err)
return
}
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 {
return fail(e, contract.ErrTextNotReady)
fail(w, contract.ErrTextNotReady)
return
}
structure, err := h.structureRepo.GetByID(*record.StructureID)
if err != nil {
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 {
return fail(e, contract.ErrTextNotReady)
fail(w, contract.ErrTextNotReady)
return
}
replicas := make([]ReplicaView, 0, len(structure.Replicas))
for _, r := range structure.Replicas {
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
for _, replica := range structure.Replicas {
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 разрешает темы всех записей страницы **одним** запросом: страница в
// сотню записей иначе стоила бы сотни обращений к хранилищу.
// сотню записей иначе стоила бы сотни обращений к базе.
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
seen := map[string]bool{}
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 {
views := []string{}
@@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
// журналом, который пишет чтение текста.
// из-за неё карточку хуже, чем недосказать.
func (h *AppHandler) hasText(textID *string) bool {
if textID == nil {
return false
@@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool {
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
// составлять ключ руками значило бы завязаться на порядок сортировки.
//
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
// ключ уезжает параметром, а не телом.
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
// телом.
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) {
@@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) {
}
createdAt, id, ok := strings.Cut(string(decoded), "|")
if !ok || createdAt == "" || id == "" {
if !ok {
return nil, errors.New("malformed cursor")
}
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
// обращает условие в постоянную истину или ложь — человек получает либо
// пустой архив при непустом счётчике, либо ленту с начала.
parsed, err := types.ParseDateTime(createdAt)
if err != nil || parsed.IsZero() {
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
// запрос сравнением, а идентификатор — точным совпадением, и негодная
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
// ленту с начала.
parsed, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
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 приклеивает к признаку негодного ввода свой текст: причина у
// всех одна, а сказать человеку надо разное.
func errWithMessage(base error, message string) error {
return &messagedError{base: base, message: message}
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
}
type messagedError struct {
base error
message string
}
func (e *messagedError) Error() string { return e.message }
func (e *messagedError) Unwrap() error { return e.base }