Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)

Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md):

- F7: oversized .torrent через веб отдавал 500. Введён sentinel
  ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400.
- F8: гонка fast-path attach с cancel. Пред-рид
  FindReingestBlockingByInfohash больше не короткозамыкает активную
  запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
  под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных
  desync-записей (target_missing/orphaned). F6-апгрейд сохранён.
- F9: magnet — регистронезависимый URN-префикс xt (RFC 2141);
  tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным
  matchем.
- F10: cap контекста до 16 KiB в ingest.Ingest (единственное место
  слияния — покрывает все транспорты), рунобезопасная обрезка + маркер.
- N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в
  U+FFFD.

Добавлены юнит-тесты на каждое исправленное поведение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 20:35:14 +03:00
co-authored by Claude Opus 4.8
parent 17d9f23630
commit b8017d65eb
9 changed files with 244 additions and 15 deletions
+11 -3
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -724,11 +725,14 @@ func desyncNote(s store.State) string {
}
}
// shorten обрезает строку до n рун (не байт), добавляя многоточие. Рунобезопасно:
// кириллица (2 байта/руна) иначе резалась бы посреди руны в U+FFFD — частый случай
// для source_ref-заголовков.
func shorten(s string, n int) string {
if len(s) <= n {
if utf8.RuneCountInString(s) <= n {
return s
}
return s[:n] + "…"
return string([]rune(s)[:n]) + "…"
}
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
@@ -761,7 +765,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404; валидация источника
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput)
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
@@ -772,6 +777,9 @@ func classifyErr(err error) (int, string) {
return http.StatusNotFound, "не найдено"
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, ingest.ErrTorrentTooLarge):
// Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500.
return http.StatusBadRequest, "файл .torrent слишком большой"
case errors.Is(err, worker.ErrInvalidInput):
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
// не сбой сервера.