приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+235
View File
@@ -0,0 +1,235 @@
package http
import (
"errors"
"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/entity"
)
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
const (
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeBadRequest = "bad_request"
CodeTooLarge = "too_large"
CodeNotReady = "not_ready"
CodeTooManyRequests = "too_many_requests"
CodeInternal = "internal"
)
// ErrorBody — единая форма тела отказа на всех адресах приложения.
//
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
// журнале владельца сервиса.
//
// Limit заполняется только у отказа по размеру: экран обязан показать предел
// числом, а не пересказать его словами.
type ErrorBody struct {
Code string `json:"error_code"`
Message string `json:"message"`
Limit *int64 `json:"limit,omitempty"`
}
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
// второе не говорило ему ничего.
//
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
func mapDomainError(err error) (int, ErrorBody) {
switch {
case errors.Is(err, contract.ErrBadRequest):
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
// а не библиотека.
message := "Запрос составлен неверно"
var owned *messagedError
if errors.As(err, &owned) {
message = owned.message
}
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
case errors.Is(err, contract.ErrRecordUnreadable):
return http.StatusBadRequest, ErrorBody{
Code: CodeBadRequest,
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
}
case errors.Is(err, contract.ErrRecordTooLarge):
limit := entity.MaxRecordSize
return http.StatusRequestEntityTooLarge, ErrorBody{
Code: CodeTooLarge,
Message: "Запись больше допустимого размера",
Limit: &limit,
}
case errors.Is(err, contract.ErrTextNotReady):
return http.StatusConflict, ErrorBody{
Code: CodeNotReady,
Message: "Текст этого вида для записи ещё не готов",
}
case errors.Is(err, contract.ErrNotFound):
message := "Адрес не найден"
var owned *messagedError
if errors.As(err, &owned) {
message = owned.message
}
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
case errors.Is(err, contract.ErrUnauthorized):
return http.StatusUnauthorized, ErrorBody{
Code: CodeUnauthorized,
Message: "Требуется вход",
}
case errors.Is(err, contract.ErrOwnerRequired):
return http.StatusForbidden, ErrorBody{
Code: CodeForbidden,
Message: "У вашей сессии нет учётной записи пользователя",
}
}
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
// ответов иначе перебирается список заведённых записей.
var notFound *contract.JobNotFoundError
if errors.As(err, &notFound) {
return http.StatusNotFound, ErrorBody{
Code: CodeNotFound,
Message: "Запись не найдена",
}
}
return http.StatusInternalServerError, ErrorBody{
Code: CodeInternal,
Message: "Внутренняя ошибка сервиса",
}
}
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
// уходит наружу с адресов приложения.
func fail(e *core.RequestEvent, err error) error {
status, body := mapDomainError(err)
return e.JSON(status, body)
}
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
//
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
//
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
// «внутренняя ошибка» вместо предела числом.
//
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
// него, включая предел тела и ограничитель частоты.
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 переводит отказ библиотеки в перечень наших кодов. Ветви
// названы поимённо: значение вне перечня приложению разбирать нечем.
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
switch apiErr.Status {
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 — слой предъявления адресов приложения.
//
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
// поимённо: без имени пускается всякая учётная запись хранилища, включая
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
// записи он стать не может.
//
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// уложенный файл — чего сервис не умеет вовсе.
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberRequireUser",
// Сразу после слоя, который читает предъявленный токен: раньше него
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth == nil {
return fail(e, contract.ErrUnauthorized)
}
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
// предъявитель себя предъявил.
if e.Auth.Collection().Name != usersCollection {
return fail(e, contract.ErrOwnerRequired)
}
return e.Next()
},
}
}