- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
682 lines
30 KiB
Go
682 lines
30 KiB
Go
package http
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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/`, принадлежавшего встроенному
|
||
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
|
||
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
|
||
const AppRoot = "/app"
|
||
|
||
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
|
||
// одним запросом и тем обойти постраничность её же параметром.
|
||
const (
|
||
DefaultPageLimit = 30
|
||
MaxPageLimit = 100
|
||
)
|
||
|
||
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
|
||
//
|
||
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
|
||
// список, открывает карточку соседней записи и грузит новую, а бюджет
|
||
// ограничителя один на все адреса приложения и считается по адресу
|
||
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
|
||
// его пополам.
|
||
const pollBudgetShare = 8
|
||
|
||
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
|
||
//
|
||
// Выводится из настройки ограничителя частоты под корнем приложения, а не
|
||
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||
// которого сервис сам же ему обещал избежать.
|
||
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
|
||
}
|
||
|
||
func NewAppHandler(
|
||
recordRepo contract.AudioRecordRepository,
|
||
textRepo contract.TextRepository,
|
||
structureRepo contract.StructureRepository,
|
||
fileRepo contract.FileRepository,
|
||
trsService *service.TranscribeService,
|
||
logger *slog.Logger,
|
||
) *AppHandler {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &AppHandler{
|
||
recordRepo: recordRepo,
|
||
textRepo: textRepo,
|
||
structureRepo: structureRepo,
|
||
fileRepo: fileRepo,
|
||
trsService: trsService,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
|
||
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
|
||
// другую.
|
||
//
|
||
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||
// признак остановки не говорит человеку, чего ждать.
|
||
//
|
||
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
|
||
// выводится из рубежа записи, который карточка несёт и так. Второе поле
|
||
// повторяло бы рубеж и разошлось бы с ним молча.
|
||
type RecordView struct {
|
||
ID string `json:"id"`
|
||
Title *string `json:"title"`
|
||
OriginalFilename *string `json:"original_filename"`
|
||
Brief *string `json:"brief"`
|
||
Topics []string `json:"topics"`
|
||
State string `json:"state"`
|
||
Halted bool `json:"halted"`
|
||
HaltReason *string `json:"halt_reason"`
|
||
DurationMs *int64 `json:"duration_ms"`
|
||
SizeBytes *int64 `json:"size_bytes"`
|
||
CreatedAt string `json:"created_at"`
|
||
// AvailableViews — перечень доступных видов текста, а не признак «текст
|
||
// есть». Видов больше одного, и шаг завершения пишет их несколькими
|
||
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
|
||
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
|
||
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
|
||
//
|
||
// У элемента страницы поле опущено: страница видов не читает.
|
||
AvailableViews *[]string `json:"available_views,omitempty"`
|
||
}
|
||
|
||
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||
type IntakeItem struct {
|
||
RecordView
|
||
Duplicate bool `json:"duplicate"`
|
||
}
|
||
|
||
type PageView struct {
|
||
Items []RecordView `json:"items"`
|
||
NextCursor *string `json:"next_cursor"`
|
||
TotalItems int `json:"total_items"`
|
||
}
|
||
|
||
type MeView struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type ConfigView struct {
|
||
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
|
||
MaxPageSize int `json:"max_page_size"`
|
||
PollIntervalMs int64 `json:"poll_interval_ms"`
|
||
KnownExtensions []string `json:"known_extensions"`
|
||
MaxTopicsPerRecord int `json:"max_topics_per_record"`
|
||
}
|
||
|
||
type TextView struct {
|
||
View string `json:"view"`
|
||
Contents string `json:"contents,omitempty"`
|
||
Replicas []ReplicaView `json:"replicas,omitempty"`
|
||
}
|
||
|
||
type ReplicaView struct {
|
||
StartMs int64 `json:"start_ms"`
|
||
EndMs int64 `json:"end_ms"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
// Routes — адреса приложения одним обработчиком.
|
||
//
|
||
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
|
||
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
|
||
// адресного пространства. Так область их действия выводится из объявленного
|
||
// пространства, а не перечисляется вторым списком.
|
||
//
|
||
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
|
||
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
|
||
// приложения одна.
|
||
func (h *AppHandler) Routes() http.Handler {
|
||
mux := http.NewServeMux()
|
||
|
||
for _, pattern := range AppRoutePatterns {
|
||
mux.HandleFunc(pattern, h.handlerOf(pattern))
|
||
}
|
||
|
||
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
|
||
// же: он принадлежит корню приложения, адресом приложения не является и
|
||
// потому отвечает как неизвестный путь под ним.
|
||
//
|
||
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
|
||
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
|
||
// закрытый перечень образцов они не входят: под них подходит **всё**, что
|
||
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
|
||
mux.HandleFunc(AppRoot+"/", h.notFound)
|
||
mux.HandleFunc(AppRoot, h.notFound)
|
||
|
||
return mux
|
||
}
|
||
|
||
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
|
||
// вешаются обработчики, и из него же берётся значение `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)
|
||
|
||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||
// наружу наравне с журналом.
|
||
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
|
||
}
|
||
|
||
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
|
||
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||
// до первого отказа на записи, которую человек уже успел отправить.
|
||
writeJSON(w, http.StatusOK, ConfigView{
|
||
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||
MaxPageSize: MaxPageLimit,
|
||
PollIntervalMs: PollIntervalMs,
|
||
KnownExtensions: metrics.PublicFormats(),
|
||
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
|
||
})
|
||
}
|
||
|
||
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 {
|
||
// Предел тела ловит запись на чтении. Не различив этот отказ и
|
||
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
|
||
// записи, которую он приложил и которая просто больше потолка.
|
||
var tooLarge *http.MaxBytesError
|
||
if errors.As(err, &tooLarge) {
|
||
fail(w, contract.ErrRecordTooLarge)
|
||
return
|
||
}
|
||
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := file.Close(); err != nil {
|
||
h.logger.Error("Failed to close uploaded file", "error", err)
|
||
}
|
||
}()
|
||
|
||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||
// может и позже — карточкой записи.
|
||
ctx := context.WithoutCancel(r.Context())
|
||
|
||
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
|
||
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
|
||
// чужое имя.
|
||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
|
||
if err != nil {
|
||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||
// делает это одним местом — по причине отказа, а не по месту.
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||
writeJSON(w, http.StatusCreated, []IntakeItem{{
|
||
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||
// отсутствие поля и пустой перечень приложение не различит.
|
||
RecordView: h.viewOf(record, nil, &[]string{}),
|
||
}})
|
||
}
|
||
|
||
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||
account, _ := AccountOf(r)
|
||
|
||
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 {
|
||
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||
return
|
||
}
|
||
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||
// сервис отдаёт, но просьба сама по себе не негодна.
|
||
q.Limit = min(limit, MaxPageLimit)
|
||
}
|
||
|
||
if raw := r.URL.Query().Get("filter"); raw != "" {
|
||
filter, ok := entity.ParseListFilter(raw)
|
||
if !ok {
|
||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||
return
|
||
}
|
||
q.Filter = &filter
|
||
}
|
||
|
||
if raw := r.URL.Query().Get("cursor"); raw != "" {
|
||
cursor, err := decodeCursor(raw)
|
||
if err != nil {
|
||
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||
// архив, листающийся по кругу, и ни строки в журнале.
|
||
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", account.ID)
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
names, err := h.topicNames(account.ID, page.Items)
|
||
if err != nil {
|
||
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}
|
||
for _, record := range page.Items {
|
||
// Страница видов текста не читает: перечень доступных видов есть только у
|
||
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
|
||
// «текста нет».
|
||
view.Items = append(view.Items, h.viewOf(record, names, nil))
|
||
}
|
||
if page.NextCursor != nil {
|
||
encoded := encodeCursor(page.NextCursor)
|
||
view.NextCursor = &encoded
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, view)
|
||
}
|
||
|
||
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||
account, _ := AccountOf(r)
|
||
|
||
record, err := h.readOwn(r, account.ID)
|
||
if err != nil {
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
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)
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
views := h.availableViews(record)
|
||
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
|
||
}
|
||
|
||
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
|
||
account, _ := AccountOf(r)
|
||
|
||
view := r.URL.Query().Get("view")
|
||
if !entity.IsKnownTextView(view) {
|
||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||
return
|
||
}
|
||
|
||
record, err := h.readOwn(r, account.ID)
|
||
if err != nil {
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
if view == entity.TextViewReplicas {
|
||
h.replicasOf(w, record)
|
||
return
|
||
}
|
||
h.plainTextOf(w, record, view)
|
||
}
|
||
|
||
// 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, ownerID)
|
||
if err != nil {
|
||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
|
||
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
|
||
// узнаёт ниоткуда.
|
||
var notFound *contract.JobNotFoundError
|
||
if !errors.As(err, ¬Found) {
|
||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||
}
|
||
return nil, err
|
||
}
|
||
return record, nil
|
||
}
|
||
|
||
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 {
|
||
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)
|
||
fail(w, err)
|
||
return
|
||
}
|
||
if text.Contents == "" {
|
||
fail(w, contract.ErrTextNotReady)
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||
}
|
||
|
||
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
|
||
if record.StructureID == nil {
|
||
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)
|
||
fail(w, err)
|
||
return
|
||
}
|
||
if len(structure.Replicas) == 0 {
|
||
fail(w, contract.ErrTextNotReady)
|
||
return
|
||
}
|
||
|
||
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||
for _, replica := range structure.Replicas {
|
||
replicas = append(replicas, ReplicaView{
|
||
StartMs: replica.StartMs,
|
||
EndMs: replica.EndMs,
|
||
Text: replica.Text,
|
||
})
|
||
}
|
||
|
||
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{}
|
||
for _, record := range records {
|
||
for _, id := range record.TopicIDs {
|
||
if !seen[id] {
|
||
seen[id] = true
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
}
|
||
return h.recordRepo.ResolveTopicNames(ownerID, ids)
|
||
}
|
||
|
||
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
|
||
topics := make([]string, 0, len(record.TopicIDs))
|
||
for _, id := range record.TopicIDs {
|
||
if name, ok := names[id]; ok {
|
||
topics = append(topics, name)
|
||
}
|
||
}
|
||
|
||
return RecordView{
|
||
ID: record.Id,
|
||
Title: record.Title,
|
||
OriginalFilename: record.OriginalFilename,
|
||
Brief: record.Brief,
|
||
Topics: topics,
|
||
State: record.State,
|
||
Halted: record.IsHalted(),
|
||
HaltReason: record.HaltReason,
|
||
DurationMs: record.DurationMs,
|
||
SizeBytes: record.SizeBytes,
|
||
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||
AvailableViews: views,
|
||
}
|
||
}
|
||
|
||
// availableViews — какие виды текста у записи есть **сейчас**.
|
||
//
|
||
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
|
||
// достижимо, потому что шаг завершения пишет их несколькими операциями.
|
||
//
|
||
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
|
||
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||
views := []string{}
|
||
|
||
if h.hasText(record.TranscriptTextID) {
|
||
views = append(views, entity.TextViewTranscript)
|
||
}
|
||
if h.hasText(record.LiteraryTextID) {
|
||
views = append(views, entity.TextViewLiterary)
|
||
}
|
||
if h.hasReplicas(record.StructureID) {
|
||
views = append(views, entity.TextViewReplicas)
|
||
}
|
||
return views
|
||
}
|
||
|
||
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||
// из-за неё карточку хуже, чем недосказать.
|
||
func (h *AppHandler) hasText(textID *string) bool {
|
||
if textID == nil {
|
||
return false
|
||
}
|
||
text, err := h.textRepo.GetByID(*textID)
|
||
if err != nil {
|
||
h.logger.Error("Failed to read text while listing views", "error", err)
|
||
return false
|
||
}
|
||
return text.Contents != ""
|
||
}
|
||
|
||
func (h *AppHandler) hasReplicas(structureID *string) bool {
|
||
if structureID == nil {
|
||
return false
|
||
}
|
||
structure, err := h.structureRepo.GetByID(*structureID)
|
||
if err != nil {
|
||
h.logger.Error("Failed to read structure while listing views", "error", err)
|
||
return false
|
||
}
|
||
return len(structure.Replicas) > 0
|
||
}
|
||
|
||
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
|
||
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||
//
|
||
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
|
||
// телом.
|
||
func encodeCursor(c *contract.RecordCursor) string {
|
||
return base64.RawURLEncoding.EncodeToString(
|
||
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
|
||
)
|
||
}
|
||
|
||
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("cursor is not decodable: %w", err)
|
||
}
|
||
|
||
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||
if !ok {
|
||
return nil, errors.New("malformed cursor")
|
||
}
|
||
|
||
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
|
||
// запрос сравнением, а идентификатор — точным совпадением, и негодная
|
||
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
|
||
// ленту с начала.
|
||
parsed, err := time.Parse(time.RFC3339, createdAt)
|
||
if err != nil {
|
||
return nil, errors.New("cursor carries no readable time")
|
||
}
|
||
|
||
recordID, valid := ident.Parse(id)
|
||
if !valid {
|
||
return nil, errors.New("cursor carries no readable record key")
|
||
}
|
||
|
||
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
|
||
}
|