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 }