// Package httpapi предоставляет HTTP API и веб-UI (server-rendered). // // Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) — // в worker, чтение — в store. В v1 без авторизации (доверенная LAN). package httpapi import ( "context" "encoding/json" "errors" "fmt" "html/template" "io/fs" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/magnet" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/web" ) // Ingestor принимает загрузку (ingest.Service). type Ingestor interface { Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error) } // Commander исполняет команды над задачей (worker.Worker). type Commander interface { Cancel(ctx context.Context, id string) error Retry(ctx context.Context, id string) error } // Reader читает задачи (store.Store). type Reader interface { ListDownloads(ctx context.Context) ([]store.Download, error) ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error) GetDownload(ctx context.Context, id string) (*store.Download, error) // LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из // загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке). LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) } // Deps — зависимости транспорта. type Deps struct { Logger *slog.Logger Ingestor Ingestor Commander Commander Reader Reader Reviewer Reviewer Live LiveStatus // Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC. Loc *time.Location } type server struct { deps Deps tmpl *template.Template assetVer string } // NewRouter собирает HTTP-обработчик сервиса. func NewRouter(d Deps) (http.Handler, error) { assetVer, err := assetVersion() if err != nil { return nil, err } funcs := template.FuncMap{ "add": func(a, b int) int { return a + b }, "asset": func(p string) string { return "/static/" + p + "?v=" + assetVer }, "badgeLabel": badgeLabel, } tmpl, err := template.New("").Funcs(funcs). ParseFS(web.FS, "templates/*.html", "templates/partials/*.html") if err != nil { return nil, err } staticFS, err := fs.Sub(web.FS, "static") if err != nil { return nil, err } if d.Live == nil { d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно } if d.Loc == nil { d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC } s := &server{deps: d, tmpl: tmpl, assetVer: assetVer} r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Use(requestLogger(d.Logger)) r.Get("/healthz", handleHealthz) // Статика (встроенная, с длинным кэшем; URL версионируются ?v=). r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS))) // Веб-UI. r.Get("/", s.handleIndex) r.Get("/download/{id}", s.handleDownload) // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) // Карточка целиком: самополлинг catched до перехода в downloading (бейдж, // имя и появившийся прогресс обновляются без перезагрузки). r.Get("/fragments/downloads/{id}/card", s.handleFragCard) // Тело ревью для поллинга recognizing (htmx-своп до готового плана). r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) // Веб-UI: ревью раскладки. r.Get("/review/{id}", s.handleReview) r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/provider", s.handleSetProvider) r.Post("/ui/downloads/{id}/source", s.handleAddSource) r.Post("/ui/downloads/{id}/nobase", s.handleNoBase) r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/relink", s.handleRelink) // REST API. r.Route("/api", func(r chi.Router) { r.Get("/downloads", s.handleAPIList) r.Post("/downloads", s.handleAPIAdd) r.Get("/downloads/{id}", s.handleAPIGet) r.Post("/downloads/{id}/cancel", s.handleAPICancel) r.Post("/downloads/{id}/retry", s.handleAPIRetry) }) return r, nil } func handleHealthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // --- Веб-UI --- // pageSize — размер страницы списка загрузок (серверная пагинация). const pageSize = 25 type indexView struct { Error string Downloads []downloadView // Фильтр/поиск (серверные, в query). Filter string // активная группа (all/review/active/done/problem) Query string // текст поиска ShowAll bool // показывать удалённые Chips []filterChip // чипы фильтра со ссылками ShowURL string // ссылка тумблера «показать всё» // Пагинация. Page int Pages int // всего страниц (>=1) Total int // всего строк под фильтром Searching bool // активны фильтр/поиск — влияет на текст пустого состояния PrevURL string // пусто — на первой странице NextURL string // пусто — на последней PageLinks []pageLink // пронумерованные страницы (окно) } type filterChip struct { Key string Label string URL string Active bool } type pageLink struct { Num int URL string Active bool } type downloadView struct { ID string Title string // отображаемый заголовок карточки State string Error string Terminal bool IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг SelfPoll bool // catched → карточка сама опрашивает себя до перехода Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново Retriable bool // failed/stuck — можно повторить попытку Note string // пояснение рассинхрона (target_missing/orphaned/deleted) ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg // Обзор жизненного цикла (мета-строка карточки). Added string // абсолютная дата добавления (TZ сервера), «2006-01-02» AddedAgo string // относительная давность, «5 дней назад» Size string // размер раздачи (снимок → фолбэк по файлам → «—») Ratio string // рейтинг отдачи (снимок → «—») } // listChips — определения чипов фильтра списка (порядок = порядок показа). var listChips = []struct { Key string Label string Group store.StateGroup }{ {"all", "Все", store.GroupAll}, {"review", "Ждут меня", store.GroupReview}, {"active", "В работе", store.GroupActive}, {"done", "Готово", store.GroupDone}, {"problem", "Проблемы", store.GroupProblem}, } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() group := parseGroup(q.Get("f")) query := strings.TrimSpace(q.Get("q")) showAll := q.Get("all") == "1" page := parsePage(q.Get("page")) downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{ Group: group, Query: query, IncludeDeleted: showAll, Limit: pageSize, Offset: (page - 1) * pageSize, }) if err != nil { s.deps.Logger.Error("list downloads", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } pages := max((total+pageSize-1)/pageSize, 1) view := indexView{ Error: q.Get("err"), Filter: string(group), Query: query, ShowAll: showAll, Page: page, Pages: pages, Total: total, Searching: query != "" || group != store.GroupAll || showAll || page > 1, } // Чипы: сохраняют q/all, сбрасывают страницу. for _, c := range listChips { view.Chips = append(view.Chips, filterChip{ Key: c.Key, Label: c.Label, URL: listURL(c.Group, query, showAll, 1), Active: c.Group == group, }) } // Тумблер «показать всё» переключает all, сохраняя фильтр/поиск. view.ShowURL = listURL(group, query, !showAll, 1) // Пагинация: сохраняет f/q/all. if page > 1 { view.PrevURL = listURL(group, query, showAll, page-1) } if page < pages { view.NextURL = listURL(group, query, showAll, page+1) } for _, n := range pageWindow(page, pages) { view.PageLinks = append(view.PageLinks, pageLink{ Num: n, URL: listURL(group, query, showAll, n), Active: n == page, }) } // Суммарный размер разложенных файлов по странице — фолбэк размера раздачи, // когда торрента нет в живом снимке (один батч-запрос, не N+1). ids := make([]string, len(downloads)) for i, d := range downloads { ids[i] = d.ID } layoutSizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), ids) if err != nil { s.deps.Logger.Error("layout sizes", "error", err) layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает } now := time.Now() for _, d := range downloads { view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID])) } s.render(w, "index.html", view) } // buildCardView собирает представление карточки списка из доменных данных и // живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки // после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку // (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести // прогресс-поллер — поэтому Progress заполняется здесь. func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { v := s.toView(d, now) // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг // и размер нужны в любом состоянии, пока торрент есть в qBittorrent. l, ok := s.liveFor(d) v.Ratio = ratioText(l, ok) v.Size = sizeText(l, ok, layoutSize) // Живой прогресс активных загрузок — со значениями уже в первом кадре // (без мигания); дальше карточка дозапрашивает фрагмент поллингом. if v.IsDownloading { v.Progress = buildProgress(d.ID, true, l, ok) } return v } // parseGroup разбирает параметр фильтра `f`; неизвестное → all. func parseGroup(s string) store.StateGroup { switch store.StateGroup(s) { case store.GroupReview: return store.GroupReview case store.GroupActive: return store.GroupActive case store.GroupDone: return store.GroupDone case store.GroupProblem: return store.GroupProblem default: return store.GroupAll } } // parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней // страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка). func parsePage(s string) int { n, err := strconv.Atoi(s) if err != nil || n < 1 { return 1 } return n } // listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы. // Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый. func listURL(group store.StateGroup, query string, showAll bool, page int) string { v := url.Values{} if group != store.GroupAll { v.Set("f", string(group)) } if query != "" { v.Set("q", query) } if showAll { v.Set("all", "1") } if page > 1 { v.Set("page", strconv.Itoa(page)) } if len(v) == 0 { return "/" } return "/?" + v.Encode() } // pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация // не разрасталась на больших списках. func pageWindow(page, pages int) []int { const win = 7 if pages <= win { out := make([]int, pages) for i := range out { out[i] = i + 1 } return out } start := max(page-win/2, 1) end := start + win - 1 if end > pages { end = pages start = end - win + 1 } out := make([]int, 0, win) for n := start; n <= end; n++ { out = append(out, n) } return out } func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { redirectErr(w, r, "не удалось разобрать форму") return } res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{ Source: r.PostForm.Get("source"), Context: r.PostForm.Get("context"), }) if err != nil { redirectErr(w, r, userErr(r, err, res.DownloadID)) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { redirectErr(w, r, "некорректный id") return } s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id)) } func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { redirectErr(w, r, "некорректный id") return } s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id)) } // surfaceAction завершает мутирующее действие, доступное и в списке, и на // странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той // поверхности, откуда пришло действие (скрытое поле surface=list|download), на // ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM). // Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда: // нет htmx → навигация). actionErr — результат доменного вызова. func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) { if !isHTMX(r) { if actionErr != nil { redirectErr(w, r, userErr(r, actionErr, id)) return } http.Redirect(w, r, "/", http.StatusSeeOther) return } if r.PostFormValue("surface") == "download" { s.renderDownloadFragment(w, r, id, actionErr) return } s.renderCardFragment(w, r, id, actionErr) } // renderCardFragment перечитывает загрузку и рендерит партиал карточки списка // (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно // отвечает 200 — htmx не свопит DOM на 4xx/5xx. func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { s.fragErr(w, err, id) return } sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) if err != nil { s.deps.Logger.Error("layout sizes", "download_id", id, "error", err) sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает } v := s.buildCardView(*d, time.Now(), sizes[id]) if actionErr != nil { v.ActionError = userErr(r, actionErr, id) } s.render(w, "card", v) } // renderDownloadFragment перечитывает загрузку и рендерит главную область // страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200. func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) if err != nil { s.fragErr(w, err, id) return } v := s.buildDownloadView(id, rd) if actionErr != nil { v.ActionError = userErr(r, actionErr, id) } s.render(w, "download_main", v) } // --- REST API --- type downloadDTO struct { ID string `json:"id"` // ULID (lowercase) SourceType string `json:"source_type"` Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2) Context string `json:"context,omitempty"` State string `json:"state"` ErrorCode string `json:"error_code,omitempty"` ErrorMsg string `json:"error_msg,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type addRequest struct { Source string `json:"source"` Context string `json:"context"` } type addResponse struct { ID string `json:"id"` // ULID (lowercase) Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO State string `json:"state"` Deduplicated bool `json:"deduplicated"` } func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) { downloads, err := s.deps.Reader.ListDownloads(r.Context()) if err != nil { s.apiErr(w, r, err, "") return } out := make([]downloadDTO, 0, len(downloads)) for _, d := range downloads { out = append(out, toDTO(d)) } writeJSON(w, http.StatusOK, out) } func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { // Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим. writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", "")) return } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { // ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404). s.apiErr(w, r, err, id) return } writeJSON(w, http.StatusOK, toDTO(*d)) } func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) { var req addRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", "")) return } res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) if err != nil { // res.DownloadID непуст, если сбой после создания задачи (напр. qbit) — // тогда коррелируем по download_id, иначе (ранний разбор источника) по // request_id. s.apiErr(w, r, err, res.DownloadID) return } status := http.StatusCreated if res.Deduplicated { status = http.StatusOK } writeJSON(w, status, addResponse{ ID: res.DownloadID, Infohashes: res.Infohashes, State: string(res.State), Deduplicated: res.Deduplicated, }) } func (s *server) handleAPICancel(w http.ResponseWriter, r *http.Request) { s.apiCommand(w, r, s.deps.Commander.Cancel) } func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) { s.apiCommand(w, r, s.deps.Commander.Retry) } func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) { id, err := pathID(r) if err != nil { // Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим. writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", "")) return } if err := cmd(r.Context(), id); err != nil { // Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в // статус+сообщение и не логируем повторно (доменный слой уже залогировал, // а невалидный ввод — норма, разбирать команде нечего). s.apiErr(w, r, err, id) return } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { writeJSON(w, http.StatusOK, map[string]string{"id": id}) return } writeJSON(w, http.StatusOK, toDTO(*d)) } // --- helpers --- func toDTO(d store.Download) downloadDTO { return downloadDTO{ ID: d.ID, SourceType: string(d.SourceType), Infohashes: d.HashList(), Context: d.Context, State: string(d.State), ErrorCode: d.ErrorCode.String, ErrorMsg: d.ErrorMsg.String, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } } func (s *server) toView(d store.Download, now time.Time) downloadView { state := string(d.State) v := downloadView{ ID: d.ID, Title: downloadTitle(d), State: state, Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), IsDownloading: d.State == store.StateDownloading, SelfPoll: d.State == store.StateCatched, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || d.State == store.StateTargetMissing, Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Note: desyncNote(d.State), } // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // как в порядке списка); неразбираемое время просто опускаем. if t, ok := addedTime(d); ok { v.Added = fmtDate(t, s.deps.Loc) v.AddedAgo = humanizeAge(t, now) } return v } // downloadTitle — заголовок загрузки для списка: имя раздачи (display_name, // то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) → // усечённый до одной строки сырой источник. Сырой magnet не должен занимать // несколько строк заголовка. func downloadTitle(d store.Download) string { if d.DisplayName != "" { return d.DisplayName } if d.RecTitle.Valid && d.RecTitle.String != "" { return d.RecTitle.String } return shorten(oneLine(d.SourceRef), 80) } // oneLine схлопывает переводы строк и лишние пробелы — сырой источник в // заголовок кладём одной строкой. func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } // addedTime — базис даты добавления карточки: время добавления в источник // (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки // (created_at), согласованно с порядком списка. ok=false — распарсить нечего. func addedTime(d store.Download) (time.Time, bool) { s := d.CreatedAt if d.SourceAddedAt.Valid && d.SourceAddedAt.String != "" { s = d.SourceAddedAt.String } t, err := store.ParseTime(s) if err != nil { return time.Time{}, false } return t, true } // desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation). func desyncNote(s store.State) string { switch s { case store.StateTargetMissing: return "разложено, но файлов в библиотеке нет — можно привязать заново" case store.StateOrphaned: return "источник удалён — это последняя копия данных, откат недоступен" case store.StateDeleted: return "удалены и источник, и файлы в библиотеке" default: return "" } } func shorten(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } // pathID валидирует {id} из URL как ULID и нормализует к lowercase — до // любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id // трактуется вызывающими как несуществующая сущность (404). func pathID(r *http.Request) (string, error) { return ident.Parse(chi.URLParam(r, "id")) } // liveFor достаёт живую телеметрию по любому из хешей загрузки. func (s *server) liveFor(d store.Download) (worker.Live, bool) { for _, h := range d.HashList() { if l, ok := s.deps.Live.Live(h); ok { return l, true } } return worker.Live{}, false } func redirectErr(w http.ResponseWriter, r *http.Request, msg string) { http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusSeeOther) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное // человекочитаемое сообщение публичного канала (без сырого err.Error() и // деталей реализации): ErrNotFound → 404, валидация источника // (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409, // прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём // только сообщение + корреляционный ключ. func classifyErr(err error) (int, string) { switch { case errors.Is(err, store.ErrNotFound): return http.StatusNotFound, "не найдено" case errors.Is(err, magnet.ErrNotMagnet): return http.StatusBadRequest, "некорректный источник" case errors.Is(err, worker.ErrConflict): // Нормальный конфликт состояния (операция недопустима сейчас), не сбой. return http.StatusConflict, "действие недоступно в текущем состоянии" case errors.Is(err, errManualSource): // Ошибка ручного ввода источника — показываем пользователю как есть. return http.StatusBadRequest, errManualSource.Error() case errors.Is(err, errInvalidCandidate): return http.StatusBadRequest, errInvalidCandidate.Error() default: return http.StatusInternalServerError, "внутренняя ошибка" } } // errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ // для владельца (download_id, если операция привязана к загрузке, иначе // request_id запроса), по которому он найдёт полную ошибку в логах. func errBody(r *http.Request, msg string, downloadID string) map[string]any { body := map[string]any{"error": msg} if downloadID != "" { body["download_id"] = downloadID } else { body["request_id"] = middleware.GetReqID(r.Context()) } return body } // apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело). func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) { status, msg := classifyErr(err) writeJSON(w, status, errBody(r, msg, downloadID)) } // userErr — сообщение публичного канала для веб-UI: нейтральный текст по // доменной ошибке + корреляционный ключ владельцу (download_id, если операция // привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт. func userErr(r *http.Request, err error, downloadID string) string { _, msg := classifyErr(err) if downloadID != "" { return fmt.Sprintf("%s (download_id=%s)", msg, downloadID) } return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context())) } // requestLogger пишет структурированный лог по каждому запросу. Служебные и // навигационные GET (healthcheck, страницы веб-UI, статика) пишем на DEBUG, // чтобы не зашумлять INFO; мутации и REST API остаются на INFO. func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) start := time.Now() next.ServeHTTP(ww, r) // http.route — низкокардинальный шаблон маршрута (chi), а не // конкретный путь; при отсутствии шаблона падаем на путь. route := chi.RouteContext(r.Context()).RoutePattern() if route == "" { route = r.URL.Path } logger.Log(r.Context(), requestLogLevel(r), "http request", "transport", "http", "http.method", r.Method, "http.route", route, "http.status_code", ww.Status(), "bytes", ww.BytesWritten(), "duration_ms", time.Since(start).Milliseconds(), "request_id", middleware.GetReqID(r.Context()), ) }) } } // requestLogLevel понижает уровень для служебных и навигационных запросов: // healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST // API (`/api/...`) остаются на INFO. func requestLogLevel(r *http.Request) slog.Level { switch { case r.URL.Path == "/healthz": return slog.LevelDebug case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"): return slog.LevelDebug default: return slog.LevelInfo } }