package http import ( "context" "errors" "log/slog" "net/http" "time" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/router" "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/service" ) type TranscribeHandler struct { recordRepo contract.AudioRecordRepository textRepo contract.TextRepository trsService *service.TranscribeService logger *slog.Logger } func NewTranscribeHandler( recordRepo contract.AudioRecordRepository, textRepo contract.TextRepository, trsService *service.TranscribeService, logger *slog.Logger, ) *TranscribeHandler { if logger == nil { logger = slog.Default() } return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger} } type CreateTranscribeJobResponse struct { JobID string `json:"job_id"` State string `json:"status"` } // GetTranscribeJobResponse — ответ об одной записи. // // Имена полей нормативны и остались прежними: контракт HTTP API объявлен // проектом необратимым, и переименование поля ломает внешнюю программу молча. // Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и // это объявленная ломка. // // Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная // запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст // отказа в ответ не идёт: он принадлежит журналу владельца сервиса. type GetTranscribeJobResponse struct { JobID string `json:"job_id"` State string `json:"status"` Halted bool `json:"halted"` CreatedAt time.Time `json:"created_at"` TranscriptionText *string `json:"transcription_text,omitempty"` } // Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у // панели один, поэтому и роутер один; имена полей ответа и коды при переезде // сохранены — публичный контракт HTTP API объявлен необратимым. func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { api := r.Group("/api") // Оба адреса уходят за аутентификацию. Слой предъявления стоит перед // проверкой и действует только здесь: собственная поверхность хранилища под // него не подпадает, часть её защищена ровно тем, что браузер заголовка сам // не шлёт. api.Bind(SessionFromCookie()) // Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка // пускает всякую учётную запись хранилища, включая владельца панели, — а // записи в коллекции пользователей у него нет, и владельцем записи он стать // не может. Отказ такому предъявителю обязан наступить здесь, до чтения // тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов // сервис не умеет вовсе. api.Bind(apis.RequireAuth(migrations.UsersCollection)) // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // раньше обработчика, без строки в журнале приёма. Приём размеру не судья, // поэтому предел тела равен потолку самой записи. api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize)) api.GET("/status/{id}", h.GetTranscribeJobStatus) } func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error { // Получаем файл из формы file, header, err := e.Request.FormFile("audio") if err != nil { return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"}) } defer func() { if err := file.Close(); err != nil { h.logger.Error("Failed to close uploaded file", "error", err) } }() // Запись доехала целиком, поэтому она заводится независимо от того, дождётся // ли отправитель ответа: на контексте запроса приём терял бы полностью // загруженную запись от одного обрыва соединения, а забрать результат он // может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса, // сессия) при этом сохраняются, теряется только отмена. ctx := context.WithoutCancel(e.Request.Context()) // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, // пришедший полем запроса, дал бы всякому вошедшему право завести запись на // чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth` // уже есть и принадлежит коллекции пользователей. record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) if err != nil { // Второй раз отказ не логируем: приём назван конвенцией логирующей // границей и уже написал о нём. Транспорт переводит ошибку в ответ. return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"}) } // Возвращаем успешный ответ return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{ JobID: record.Id, State: record.State, }) } func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error { recordID := e.Request.PathValue("id") // Чужая запись, запись без владельца и несуществующая отвечают одним и тем // же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и // то же тело. Различать их наружу нельзя — по разнице ответов перебирается // список заведённых записей. record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) 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 e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"}) } response := GetTranscribeJobResponse{ JobID: record.Id, State: record.State, Halted: record.IsHalted(), CreatedAt: record.CreatedAt, } // Вид текста называется **явно**: видов у записи больше одного, и отдача // «последнего записанного» сделала бы ответ функцией порядка записи, а не // состояния записи. В это поле уходит сырая расшифровка, и только она. if record.TranscriptTextID != nil { text, err := h.textRepo.GetByID(*record.TranscriptTextID) if err != nil { h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID) return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"}) } if text.Contents != "" { contents := text.Contents response.TranscriptionText = &contents } } return e.JSON(http.StatusOK, response) }