web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+17
View File
@@ -84,6 +84,23 @@ func (s State) IsObservable() bool {
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
}
// deletableStates — состояния, из которых человеку доступно полное удаление
// (снять библиотечные ссылки + снести раздачу с файлами из qBittorrent).
// Единственный дом перечня: на него смотрят и допуск в ядре (worker.Delete), и
// все транспорты, решающие, показывать действие или нет.
var deletableStates = []State{
StateDone, StateOrphaned, StateTargetMissing,
}
// CanDelete сообщает, допускает ли состояние полное удаление загрузки
// пользователем. Транспорт спрашивает его, чтобы не завести своего перечня
// состояний; проверку в ядре это не заменяет — допуск обязан держаться без
// транспорта (см. openspec/specs/state-reconciliation, «Полное удаление
// загрузки пользователем»).
func (s State) CanDelete() bool {
return slices.Contains(deletableStates, s)
}
// allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
+19
View File
@@ -683,3 +683,22 @@ func TestIsObservable(t *testing.T) {
t.Error("failed должно быть терминальным и при этом наблюдаемым")
}
}
// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
// состояние, попавшее в перечень молча, тест покажет.
func TestCanDelete(t *testing.T) {
deletable := []State{StateDone, StateOrphaned, StateTargetMissing}
for _, s := range allStates {
want := slices.Contains(deletable, s)
if got := s.CanDelete(); got != want {
t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
}
}
// deleted терминально и удалению не подлежит — иначе повторная отправка
// формы группового удаления сносила бы уже снесённое второй раз.
if StateDeleted.CanDelete() {
t.Error("deleted не должно допускать удаление")
}
}
+36 -4
View File
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
return " WHERE " + strings.Join(conds, " AND "), args
}
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
// списка и страница группового удаления не разъехались порядком.
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
const listOrder = `
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
// полное удаление, без постраничной выдачи: страница группового удаления
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
// пачку. Перечень состояний берётся из единой точки домена — своего списка
// запрос не держит.
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
ph := make([]string, len(deletableStates))
args := make([]any, len(deletableStates))
for i, st := range deletableStates {
ph[i] = "?"
args[i] = string(st)
}
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
return out, nil
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
}
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
q := listSelect + where + listOrder + `
LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
+36
View File
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
t.Fatalf("display_name = %q", d.DisplayName)
}
}
// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
// разъезд запроса с предикатом иначе виден только на проде.
func TestListDeletableDownloads(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
all := []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
StateFailed, StateCancelled, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
byID := map[string]State{}
for i, s := range all {
byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
}
got, err := st.ListDeletableDownloads(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[State]bool{}
for _, d := range got {
if !d.State.CanDelete() {
t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
}
seen[d.State] = true
}
for _, s := range all {
if s.CanDelete() && !seen[s] {
t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
}
}
}