- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
290 lines
15 KiB
Go
290 lines
15 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||
)
|
||
|
||
// maxBulkDelete — верхний предел числа загрузок в одной пачке. Подтверждение,
|
||
// перечисляющее больше, человек не читает — то есть перестаёт быть
|
||
// подтверждением; плюс один синхронный запрос упирается в столько же
|
||
// последовательных вызовов qBittorrent. Число названо на самой странице выбора:
|
||
// предел, о котором узнают только из отказа, отнимает уже сделанную работу.
|
||
const maxBulkDelete = 20
|
||
|
||
// bulkFailThreshold — сколько подряд идущих отказов внешнего сервиса
|
||
// прекращают проход. Удаление снимает библиотечные ссылки раньше, чем сносит
|
||
// раздачу: при лежащем qBittorrent каждая единица успевает выполнить
|
||
// необратимый локальный шаг и упасть на внешнем, оставив тайтл без раскладки и
|
||
// не освободив места. Счётчик сбрасывается на успехе — одиночная сетевая
|
||
// ошибка пачку не рвёт.
|
||
const bulkFailThreshold = 3
|
||
|
||
// bulkBudget — потолок времени на один проход пачки. Удаление держит общий
|
||
// замок воркера на всё время обращения к qBittorrent, поэтому медленно, но
|
||
// успешно отвечающий сосед останавливает фоновую работу целиком, а порог
|
||
// отказов такого не ловит — он считает только ошибки. Проверяется МЕЖДУ
|
||
// единицами, а не отменой контекста: начатое удаление обрывать нельзя, иначе
|
||
// оно встанет между снятием библиотечных ссылок и сносом раздачи.
|
||
//
|
||
// Переменная, а не константа, ровно по одной причине: тест укорачивает её —
|
||
// иначе проверка потолка стоила бы двух минут прогона.
|
||
var bulkBudget = 2 * time.Minute
|
||
|
||
// Отказы разбора пачки. Текст — публичного канала: он показывается человеку
|
||
// как есть, как у прочих sentinel'ов транспорта. Трансляция в статус и
|
||
// сообщение живёт в единой точке `classifyErr`, а не рядом.
|
||
var (
|
||
errBatchEmpty = errors.New("ни одна загрузка не выбрана")
|
||
errBatchTooLarge = errors.New("за один раз можно удалить не больше " +
|
||
strconv.Itoa(maxBulkDelete) + " загрузок")
|
||
errBatchBadID = errors.New("некорректный идентификатор загрузки — запрос отклонён целиком")
|
||
errBatchForm = errors.New("форма запроса не разобрана — запрос отклонён целиком")
|
||
)
|
||
|
||
// bulkRow — строка загрузки на любом из трёх экранов группового удаления.
|
||
type bulkRow struct {
|
||
ID string
|
||
Title string
|
||
State string
|
||
Selected bool // отметка сохранена при возврате отказа
|
||
LastCopy bool // orphaned: библиотечная ссылка осталась последней копией
|
||
Missing bool // записи в хранилище нет
|
||
Unread bool // состояние прочитать не удалось (отказ хранилища)
|
||
Reason string // причина отказа (только на экране результата)
|
||
}
|
||
|
||
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
|
||
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
|
||
type bulkSelectView struct {
|
||
Error string
|
||
Max int
|
||
Rows []bulkRow
|
||
}
|
||
|
||
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
|
||
type bulkConfirmView struct {
|
||
Rows []bulkRow
|
||
}
|
||
|
||
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
|
||
// проход остановлен системным отказом.
|
||
type bulkResultView struct {
|
||
Deleted []bulkRow
|
||
Failed []bulkRow
|
||
Skipped []bulkRow
|
||
StopReason string
|
||
}
|
||
|
||
// handleBulkDeletePage — страница выбора. Самообновления не несёт сознательно:
|
||
// своп разметки унёс бы отметки, и человек подтвердил бы необратимое удаление
|
||
// по выбору, которого уже не видит (см. openspec/specs/web-ui).
|
||
func (s *server) handleBulkDeletePage(w http.ResponseWriter, r *http.Request) {
|
||
s.renderBulkSelect(w, r, "", nil)
|
||
}
|
||
|
||
// renderBulkSelect отрисовывает страницу выбора, помечая отмеченными те строки,
|
||
// чьи идентификаторы человек уже выбрал (selected). Общий путь для чистого
|
||
// открытия страницы и для любого отказа разбора.
|
||
func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg string, selected []string) {
|
||
ds, err := s.deps.Reader.ListDeletableDownloads(r.Context())
|
||
if err != nil {
|
||
s.deps.Logger.Error("list deletable downloads", "error", err)
|
||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
mark := make(map[string]bool, len(selected))
|
||
for _, id := range selected {
|
||
mark[id] = true
|
||
}
|
||
view := bulkSelectView{Error: msg, Max: maxBulkDelete}
|
||
for _, d := range ds {
|
||
view.Rows = append(view.Rows, bulkRow{
|
||
ID: d.ID,
|
||
Title: downloadTitle(d),
|
||
State: string(d.State),
|
||
Selected: mark[d.ID],
|
||
LastCopy: d.State == store.StateOrphaned,
|
||
})
|
||
}
|
||
s.render(w, "delete.html", view)
|
||
}
|
||
|
||
// handleBulkDeleteConfirm — экран подтверждения. Ничего не меняет: разбирает
|
||
// вход, читает выбранные загрузки и называет каждую поимённо.
|
||
func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||
ids, err := s.parseBulkBatch(r)
|
||
if err != nil {
|
||
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
|
||
return
|
||
}
|
||
s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
|
||
}
|
||
|
||
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
|
||
// разбора и до единого вызова удаления: подтверждение — условие операции, а не
|
||
// украшение экрана.
|
||
func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil || r.PostForm.Get("confirm") != "1" {
|
||
s.renderBulkSelect(w, r, "Удаление уходит только со страницы подтверждения.", nil)
|
||
return
|
||
}
|
||
// Исполняющий запрос — самостоятельная входная граница: идентификаторы
|
||
// приходят формой заново, состояния между шагами сервис не хранит.
|
||
ids, err := s.parseBulkBatch(r)
|
||
if err != nil {
|
||
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
|
||
return
|
||
}
|
||
|
||
// Контекст исполнения отвязан от запроса: обрыв связи не вправе оборвать
|
||
// необратимую операцию на середине — в том числе внутри одной загрузки,
|
||
// между снятием библиотечных ссылок и сносом раздачи. Строки отчёта читаются
|
||
// тем же контекстом: собранные отменённым, они превратили бы весь отчёт в
|
||
// «загрузка не найдена» ровно там, где удаление идёт штатно.
|
||
ctx := context.WithoutCancel(r.Context())
|
||
rows := s.bulkRows(ctx, ids)
|
||
|
||
var res bulkResultView
|
||
streak := 0
|
||
deadline := store.Now().Add(bulkBudget)
|
||
for i, row := range rows {
|
||
if res.StopReason != "" {
|
||
res.Skipped = append(res.Skipped, rows[i])
|
||
continue
|
||
}
|
||
if store.Now().After(deadline) {
|
||
res.StopReason = "Проход занял дольше отведённого времени и остановлен: " +
|
||
"пока идёт пачка, остальная работа сервиса ждёт."
|
||
res.Skipped = append(res.Skipped, rows[i])
|
||
continue
|
||
}
|
||
err := s.deps.Reviewer.Delete(ctx, row.ID)
|
||
if err == nil {
|
||
streak = 0
|
||
res.Deleted = append(res.Deleted, row)
|
||
continue
|
||
}
|
||
row.Reason = userErr(r, err, row.ID)
|
||
res.Failed = append(res.Failed, row)
|
||
// Конфликт состояния и отсутствие записи — про саму задачу, а не про
|
||
// доступность соседа: счётчик системных отказов они не двигают.
|
||
if errors.Is(err, worker.ErrConflict) || errors.Is(err, store.ErrNotFound) {
|
||
continue
|
||
}
|
||
streak++
|
||
if streak >= bulkFailThreshold {
|
||
res.StopReason = "Внешний сервис отказывает подряд — проход остановлен, " +
|
||
"чтобы не снимать раскладку у остальных без освобождения места."
|
||
}
|
||
}
|
||
// Исход каждой единицы поимённо: ответ мог не дойти (вкладку закрыли), и
|
||
// журнал — единственное, по чему потом видно, что снесено, что отказало и до
|
||
// чего проход не дошёл. На воркер полагаться нельзя: отказы по конфликту и
|
||
// отсутствию записи он пишет на DEBUG.
|
||
for _, row := range res.Deleted {
|
||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "deleted")
|
||
}
|
||
for _, row := range res.Failed {
|
||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "failed",
|
||
"reason", row.Reason)
|
||
}
|
||
for _, row := range res.Skipped {
|
||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "skipped")
|
||
}
|
||
s.deps.Logger.Info("bulk delete finished",
|
||
"requested", len(rows), "deleted", len(res.Deleted),
|
||
"failed", len(res.Failed), "skipped", len(res.Skipped),
|
||
"stopped", res.StopReason != "")
|
||
|
||
s.render(w, "delete_result.html", res)
|
||
}
|
||
|
||
// bulkRows читает выбранные загрузки для показа поимённо. Идентификатор без
|
||
// записи в хранилище не выбрасывается молча — он идёт своей строкой: человек
|
||
// подтверждает пачку, и она обязана совпадать с тем, что он выбрал.
|
||
func (s *server) bulkRows(ctx context.Context, ids []string) []bulkRow {
|
||
rows := make([]bulkRow, 0, len(ids))
|
||
for _, id := range ids {
|
||
d, err := s.deps.Reader.GetDownload(ctx, id)
|
||
switch {
|
||
case errors.Is(err, store.ErrNotFound):
|
||
rows = append(rows, bulkRow{ID: id, Missing: true, Title: "загрузка не найдена"})
|
||
continue
|
||
case err != nil || d == nil:
|
||
// Отказ хранилища — это НЕ «записи нет». Выдав одно за другое, экран
|
||
// сказал бы «удалять нечего» о загрузке, которую пачка снесёт
|
||
// по-настоящему, и для orphaned унёс бы отметку последней копии —
|
||
// единственный оставшийся предохранитель. Приватный канал: пишем
|
||
// здесь, потому что выше эта ошибка не всплывает.
|
||
s.deps.Logger.Error("bulk delete: read download", "download_id", id, "error", err)
|
||
rows = append(rows, bulkRow{ID: id, Unread: true, Title: "состояние прочитать не удалось"})
|
||
continue
|
||
}
|
||
rows = append(rows, bulkRow{
|
||
ID: d.ID,
|
||
Title: downloadTitle(*d),
|
||
State: string(d.State),
|
||
LastCopy: d.State == store.StateOrphaned,
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// parseBulkBatch разбирает пачку идентификаторов с формы. Проверки одинаковы на
|
||
// обеих границах — подтверждения и исполнения. Возвращает разобранные
|
||
// идентификаторы даже вместе с отказом: страница выбора возвращает по ним
|
||
// отметки, чтобы отказ не стирал проделанную работу.
|
||
func (s *server) parseBulkBatch(r *http.Request) ([]string, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
// Приватный канал: выше эта ошибка не всплывает, а человеку про
|
||
// идентификаторы говорить нечего — тело не прочиталось целиком.
|
||
s.deps.Logger.Error("bulk delete: parse form", "error", err)
|
||
return nil, errBatchForm
|
||
}
|
||
// Только тело: признак подтверждения и пачка приходят формой, и принимать
|
||
// их из строки запроса значит принимать подтверждение оттуда, откуда
|
||
// требование его не заказывало.
|
||
raw := r.PostForm["id"]
|
||
seen := make(map[string]bool, len(raw))
|
||
ids := make([]string, 0, len(raw))
|
||
for _, v := range raw {
|
||
id, err := ident.Parse(v)
|
||
if err != nil {
|
||
// Молча пропустить нельзя: человек подтвердил удаление поимённо, и
|
||
// выброшенный идентификатор развёл бы подтверждённое с исполненным.
|
||
return ids, errBatchBadID
|
||
}
|
||
if seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
ids = append(ids, id)
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil, errBatchEmpty
|
||
}
|
||
if len(ids) > maxBulkDelete {
|
||
return ids, errBatchTooLarge
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
// bulkErrMsg — сообщение человеку об отказе разбора. Сам текст берётся из
|
||
// единой точки трансляции (`classifyErr`); здесь добавляется только подсказка,
|
||
// что делать дальше, — она осмысленна ровно на этой странице.
|
||
func bulkErrMsg(err error) string {
|
||
_, msg := classifyErr(err)
|
||
if errors.Is(err, errBatchTooLarge) {
|
||
return msg + ". Отметки сохранены — сними лишние."
|
||
}
|
||
return msg + "."
|
||
}
|