Files
jellybit/internal/httpapi/httpapi.go
T
avandClaude Opus 4.8 1f4267a046 Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:12:28 +03:00

717 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
// Deps — зависимости транспорта.
type Deps struct {
Logger *slog.Logger
Ingestor Ingestor
Commander Commander
Reader Reader
Reviewer Reviewer
Live LiveStatus
}
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{} // источник телеметрии не подключён — деградируем штатно
}
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)
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 // отображаемый заголовок карточки
Infohash string // полный (для копирования)
InfohashShort string // усечённый (для показа)
Context string
State string
Error string
Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
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)
}
// 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,
})
}
for _, d := range downloads {
v := toView(d)
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
l, ok := s.liveFor(d)
v.Progress = buildProgress(d.ID, true, l, ok)
}
view.Downloads = append(view.Downloads, v)
}
s.render(w, "index.html", view)
}
// 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
}
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- 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 toView(d store.Download) downloadView {
state := string(d.State)
return downloadView{
ID: d.ID,
Title: downloadTitle(d),
Infohash: d.PrimaryInfohash(),
InfohashShort: shortenHash(d.PrimaryInfohash()),
Context: d.Context,
State: state,
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading,
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),
}
}
// 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), " ")
}
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
func shortenHash(h string) string {
if len(h) <= 12 {
return h
}
return h[:10] + "…" + h[len(h)-2:]
}
// 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
}
}