Логирование: доменная граница ошибок + защита секретов в логах

Приём торрента через Telegram молча падал без записи в логах. Разобрали
цепочку и починили логирование/обработку ошибок по конвенции logging.md
(логирует граница домена один раз, транспорты — нет).

Доменная граница логирует исход:
- ingest.Ingest: сбой БД → ERROR, невалидный источник → DEBUG;
- команды воркера (Apply/Cancel/Retry/Refine/…) — единый чокпоинт logCmd
  (ERROR для инфраструктурного сбоя; DEBUG для conflict/not-ready/not-found),
  закрывает и Telegram-, и HTTP-путь; дублирующие ERROR-логи в tgbot сняты;
- внутренний логгер tgbotapi заведён в slog: сбои long-poll getUpdates
  больше не уходят в stdlib log мимо структурированных логов;
- тихое закрытие канала обновлений бота → ERROR.

Защита секретов (инвариант «секреты не в логи»):
- общий logging.SanitizeErr убирает URL из *url.Error;
- закрыты утечки токена бота (getMe на старте, getFile, Send/Request)
  и api_key TMDB (query-параметр, попадавший в *url.Error на ERROR);
- покрыто тестом internal/logging/sanitize_test.go.

Ревью двумя сабагентами (fable): инфраструктурные и доменные ошибки.
Отложено (не в scope этого коммита): обёртка ErrConflict в
Cancel/Defer/Retry и классификация 500→409/400, обновление docs/conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 14:29:38 +03:00
co-authored by Claude Opus 4.8
parent 5c3ef79496
commit f8fb4fabb3
11 changed files with 240 additions and 40 deletions
+9 -1
View File
@@ -182,9 +182,17 @@ func runServe(args []string) error {
if perr != nil {
return perr
}
// Внутренние логи tgbotapi (сбои long-poll getUpdates и пр.) — в наш slog
// вместо stdlib log мимо структурированных логов; токен вырезается.
if lerr := tgbot.SetLibraryLogger(logger, cfg.Telegram.Token); lerr != nil {
logger.Warn("telegram library logger not set", "error", lerr)
}
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
if terr != nil {
logger.Error("telegram bot disabled, cannot connect", "error", terr)
// NewBotAPIWithClient дёргает getMe: при недоступном Telegram/прокси
// terr — *url.Error с URL …/bot<TOKEN>/getMe; санитизируем, чтобы
// токен не утёк в лог.
logger.Error("telegram bot disabled, cannot connect", "error", logging.SanitizeErr(terr))
} else {
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
+7
View File
@@ -77,6 +77,9 @@ type Result struct {
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
src, err := s.parse(req)
if err != nil {
// Невалидный источник — норма (адресат не команда, а пользователь, и он
// получит отказ на транспорте): DEBUG, чтобы не шуметь в аудите.
s.log.Debug("ingest source rejected", "capability", capIngest, "error", err)
return Result{}, err
}
@@ -88,6 +91,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
// гибридный несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "lookup-active", "error", err)
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
} else if existing != nil {
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
@@ -109,6 +114,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// транзакции лишь на ветке создания.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
if err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "create-download", "error", err)
return Result{}, fmt.Errorf("ingest: create download: %w", err)
}
if existing != nil {
+6 -3
View File
@@ -54,12 +54,15 @@ func (c ExtCall) SuccessDebug(log *slog.Logger, extra ...any) {
log.Debug("external call", c.attrs(extra...)...)
}
// Retry логирует неудачную попытку, после которой будет повтор (WARN).
// Retry логирует неудачную попытку, после которой будет повтор (WARN). Ошибка
// санитизируется (SanitizeErr): транспортный сбой несёт URL с секретом
// (api_key TMDB в query, токен в пути) — в лог он попасть не должен.
func (c ExtCall) Retry(log *slog.Logger, err error, extra ...any) {
log.Warn("external call retry", c.attrs(append([]any{"error", err}, extra...)...)...)
log.Warn("external call retry", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
}
// Failure логирует окончательную неудачу вызова / недоступность сервиса (ERROR).
// Ошибка санитизируется (SanitizeErr) — см. Retry.
func (c ExtCall) Failure(log *slog.Logger, err error, extra ...any) {
log.Error("external call failed", c.attrs(append([]any{"error", err}, extra...)...)...)
log.Error("external call failed", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
}
+24
View File
@@ -0,0 +1,24 @@
package logging
import (
"errors"
"net/url"
)
// SanitizeErr убирает из ошибки HTTP-транспорта URL запроса и возвращает только
// первопричину. Ошибки клиента net/http — `*url.Error`, чей текст встраивает
// полный URL, а URL может нести секрет: токен бота Telegram в пути
// (`…/bot<TOKEN>/<method>`) или api_key TMDB в query
// (`…/search/movie?api_key=<SECRET>&…`). Логировать или оборачивать такую ошибку
// как есть нельзя — секрет утечёт в логи (инвариант «секреты не в логи», см.
// logging.md, раздел «Безопасность»).
//
// Логическая операция при этом не теряется: она пишется отдельным полем
// (`ext.operation`) на границе клиента. Не `*url.Error` — возвращаем как есть.
func SanitizeErr(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
+63
View File
@@ -0,0 +1,63 @@
package logging
import (
"errors"
"fmt"
"net/url"
"strings"
"testing"
)
// SanitizeErr обязана убрать URL (носитель секрета) из *url.Error, оставив
// первопричину — это единый предохранитель от утечки токена/ключа в логи.
func TestSanitizeErr(t *testing.T) {
cause := errors.New("dial tcp 1.2.3.4:443: connect: connection refused")
t.Run("url error with token in path", func(t *testing.T) {
ue := &url.Error{
Op: "Get",
URL: "https://api.telegram.org/botSECRET123:AAtoken/getUpdates",
Err: cause,
}
got := SanitizeErr(ue)
if strings.Contains(got.Error(), "SECRET123") || strings.Contains(got.Error(), "AAtoken") {
t.Fatalf("токен утёк: %v", got)
}
if !errors.Is(got, cause) {
t.Fatalf("первопричина потеряна: %v", got)
}
})
t.Run("url error with api_key in query", func(t *testing.T) {
ue := &url.Error{
Op: "Get",
URL: "https://api.themoviedb.org/3/search/movie?api_key=DEADBEEFSECRET&query=dune",
Err: cause,
}
if strings.Contains(SanitizeErr(ue).Error(), "DEADBEEFSECRET") {
t.Fatalf("api_key утёк: %v", SanitizeErr(ue))
}
})
t.Run("wrapped url error is unwrapped via errors.As", func(t *testing.T) {
wrapped := fmt.Errorf("metadata: request: %w", &url.Error{
Op: "Get", URL: "https://x/api_key=SECRET", Err: cause,
})
if strings.Contains(SanitizeErr(wrapped).Error(), "SECRET") {
t.Fatalf("секрет утёк из обёрнутой ошибки: %v", SanitizeErr(wrapped))
}
})
t.Run("non-url error passes through unchanged", func(t *testing.T) {
plain := errors.New("plain domain error")
if !errors.Is(SanitizeErr(plain), plain) {
t.Fatalf("обычная ошибка изменена: %v", SanitizeErr(plain))
}
})
t.Run("nil stays nil", func(t *testing.T) {
if SanitizeErr(nil) != nil {
t.Fatal("nil должен оставаться nil")
}
})
}
+4
View File
@@ -79,6 +79,10 @@ func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, ope
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
resp, err := hc.Do(req)
if err != nil {
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
// query-параметр: санитизируем до логирования И до обёртки, чтобы ключ
// не утёк ни в лог, ни вверх по цепочке %w.
err = logging.SanitizeErr(err)
call.Failure(log, err)
return fmt.Errorf("metadata: request: %w", err)
}
+27 -20
View File
@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -16,6 +15,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/logging"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -106,6 +106,12 @@ func (b *Bot) Run(ctx context.Context) {
return
case u, ok := <-updates:
if !ok {
// Канал закрыт не по нашей отмене (ctx ещё жив) — библиотека
// прекратила приём обновлений: иначе бот молча перестал бы
// реагировать без следа в логах.
if ctx.Err() == nil {
b.log.Error("telegram updates channel closed unexpectedly")
}
return
}
b.handleUpdate(ctx, u)
@@ -147,6 +153,8 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
// Ждём подсказку для перераспознавания?
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
if err := b.reviewer.Refine(ctx, id, text); err != nil {
// Ошибку логирует доменная граница (worker.Refine); транспорт лишь
// показывает пользователю (logging.md: «транспорты не логируют»).
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
@@ -161,6 +169,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
source, context, ok := ParseMessage(text)
if !ok {
// Невалидный ввод — норма (пользователь получит отказ): DEBUG, чтобы
// при разборе «почему не приняло» отказ был виден в логах.
b.log.Debug("telegram source not recognized", "chat_id", m.Chat.ID)
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
return
}
@@ -172,10 +183,12 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
doc := m.Document
if !isTorrentDoc(doc) {
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "not-torrent", "mime", doc.MimeType)
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
return
}
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "too-large", "size", doc.FileSize)
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
return
}
@@ -192,16 +205,6 @@ func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
})
}
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
// бота), оставляя только первопричину — защита от утечки секрета в логи.
func stripURL(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
func isTorrentDoc(doc *tgbotapi.Document) bool {
if doc == nil {
@@ -218,20 +221,22 @@ func isTorrentDoc(doc *tgbotapi.Document) bool {
//
// ВАЖНО: прямой URL файла Telegram содержит токен бота
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
// URL в текст — их нельзя возвращать/логировать как есть. logging.SanitizeErr
// оставляет только первопричину без URL, чтобы токен не утёк в логи (см.
// logging.md). GetFileDirectURL тоже ходит в Bot API (…/bot<TOKEN>/getFile) —
// его ошибку санитизируем так же.
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
fileURL, err := b.api.GetFileDirectURL(fileID)
if err != nil {
return nil, fmt.Errorf("file url: %w", err)
return nil, fmt.Errorf("file url: %w", logging.SanitizeErr(err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
return nil, fmt.Errorf("telegram file request: %w", logging.SanitizeErr(err))
}
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
return nil, fmt.Errorf("telegram file GET: %w", logging.SanitizeErr(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
@@ -340,6 +345,8 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
return
}
// Ошибку логирует доменная граница (соответствующая команда worker);
// транспорт лишь переводит её в ответ пользователю (logging.md).
b.answer(cq.ID, "Ошибка")
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
return
@@ -363,7 +370,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id strin
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
}
if _, err := b.api.Send(edit); err != nil {
b.log.Warn("telegram edit card failed", "download_id", id, "error", err)
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
}
}
@@ -402,13 +409,13 @@ func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup)
msg.ReplyMarkup = *kb
}
if _, err := b.api.Send(msg); err != nil {
b.log.Warn("telegram send failed", "chat_id", chatID, "error", err)
b.log.Warn("telegram send failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
}
}
func (b *Bot) answer(callbackID, text string) {
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
b.log.Warn("telegram answer callback failed", "error", err)
b.log.Warn("telegram answer callback failed", "error", logging.SanitizeErr(err))
}
}
@@ -419,7 +426,7 @@ func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMar
return
}
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", err)
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func TestBot_DocumentByExtension(t *testing.T) {
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
// что stripURL его убрал.
// что logging.SanitizeErr его убрал.
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
+46
View File
@@ -0,0 +1,46 @@
package tgbot
import (
"fmt"
"log/slog"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// SetLibraryLogger направляет внутренние логи клиента tgbotapi в наш slog.
//
// Зачем: библиотека логирует сбои long-poll `getUpdates` через собственный
// (stdlib `log`) логгер — МИМО slog. Из-за этого сетевые/API-ошибки поллинга не
// попадали в структурированные JSON-логи: входящее сообщение молча не
// подхватывалось, а в логах — пусто (см. logging.md). После вызова такие сбои
// видны как `telegram library` (WARN).
//
// Токен вырезается из текста: строка ошибки транспорта — `*url.Error` с URL вида
// `…/bot<TOKEN>/getUpdates`, писать её как есть нельзя (утечка секрета в логи).
// Замена по подстроке страхует и от прочих мест, где токен мог бы просочиться.
//
// Логгер в tgbotapi — глобальный на пакет; вызывать один раз при старте.
func SetLibraryLogger(log *slog.Logger, token string) error {
return tgbotapi.SetLogger(libLogger{log: log, token: token})
}
type libLogger struct {
log *slog.Logger
token string
}
func (l libLogger) Println(v ...any) { l.emit(fmt.Sprintln(v...)) }
func (l libLogger) Printf(format string, v ...any) { l.emit(fmt.Sprintf(format, v...)) }
func (l libLogger) emit(msg string) {
msg = strings.TrimSpace(msg)
if l.token != "" {
msg = strings.ReplaceAll(msg, l.token, "***")
}
// Сбои поллинга транзиентны (библиотека повторяет через 3 с) — WARN
// («retry внешнего вызова»); устойчивый сбой станет потоком WARN — сигнал
// разбираться, но не ERROR на каждый повтор.
l.log.Warn("telegram library", "detail", msg)
}
+26 -13
View File
@@ -225,7 +225,8 @@ func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]strin
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id string) error {
func (w *Worker) Apply(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "apply", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -362,7 +363,8 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
func (w *Worker) Relink(ctx context.Context, id string) error {
func (w *Worker) Relink(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "relink", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -399,7 +401,8 @@ func (w *Worker) Relink(ctx context.Context, id string) error {
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "rerecognize", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -417,7 +420,8 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error) {
defer func() { w.logCmd(ctx, "refine", id, err) }()
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
@@ -443,7 +447,8 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
defer func() { w.logCmd(ctx, "set_type", id, err) }()
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
}
@@ -474,7 +479,8 @@ func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
defer func() { w.logCmd(ctx, "ignore_file", id, err) }()
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
@@ -503,7 +509,8 @@ func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id string) error {
func (w *Worker) Defer(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "defer", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -523,7 +530,8 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id string) error {
func (w *Worker) Undo(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "undo", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -588,7 +596,8 @@ func laidOutLinks(rows []store.FileLink) []layout.Link {
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
// его отсутствие трактуем как уже снятую сторону).
func (w *Worker) Delete(ctx context.Context, id string) error {
func (w *Worker) Delete(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "delete", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -666,7 +675,8 @@ func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (err error) {
defer func() { w.logCmd(ctx, "choose_candidate", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -691,7 +701,8 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "add_manual_source", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -786,7 +797,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "set_provider_id", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -818,7 +830,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
// пины названия/года (источник — распознавание нейронкой).
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "clear_provider", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
+27 -2
View File
@@ -775,9 +775,33 @@ func (w *Worker) shouldNotifyFail(id string) bool {
return true
}
// logCmd — единый чокпоинт логирования исхода команды воркера
// (apply/cancel/retry/…), вызываемой транспортами. Конвенция (logging.md,
// раздел «Ошибки»): доменную ошибку логирует граница домена ровно один раз, а
// транспорты (HTTP/web/Telegram) — нет. Команды воркера и есть эта граница.
//
// Уровень — по адресату: штатный отказ по состоянию/наличию
// (ErrConflict/ErrNotReady/ErrNotFound) адресован пользователю, он уже получил
// ответ на поверхности — DEBUG; всё прочее (сбой БД/ФС/зависимости) адресовано
// команде — ERROR. Успех (err == nil) — молча. Ставится в defer при именованном
// возврате, поэтому видит финальную ошибку и scoped-логгер, накопленный в ctx.
func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
if err == nil {
return
}
log := logctx.FromOr(ctx, w.log)
switch {
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, store.ErrNotFound):
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
default:
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
}
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id string) error {
func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "cancel", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -797,7 +821,8 @@ func (w *Worker) Cancel(ctx context.Context, id string) error {
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id string) error {
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "retry", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()