- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
13 KiB
13 KiB
1. Единая точка допуска
- 1.1 Завести
(store.State).CanDelete()рядом сIsTerminal/IsObservable, переченьdone/orphaned/target_missing— одним списком - 1.2 Перевести
switchвworker.DeleteнаCanDelete(проверку в ядре не снимать) - 1.3 Перевести сборку
Deletableвinternal/httpapi/download.goнаCanDelete - 1.4 Перевести выбор клавиатуры в
internal/tgbot/render.goнаCanDelete— иначе Telegram остаётся четвёртым перечнем состояний - 1.5 Тест:
CanDeleteистинно ровно для трёх состояний и ложно для остальных (перебор всех состояний) - 1.6 Тест
internal/tgbot: клавиатура с действием удаления приходит ровно для тех состояний, гдеCanDeleteистинно
2. Чтение списка разрешённых к удалению
- 2.1 Добавить в
Readerметод выборки загрузок, разрешённых к удалению, без пагинации; порядок — как в основном списке - 2.2 Реализовать выборку в
storeчерез существующую механику фильтра по состояниям (перечень состояний берётся из единой точки, второго списка не заводить) - 2.3 Тест
store: выборка возвращает толькоdone/orphaned/target_missing
3. Страница выбора
- 3.1 Шаблон страницы
/delete: строки с чекбоксами, заголовок, состояние каждой загрузки, кнопка «Удалить выбранные», названный предел размера пачки рядом с кнопкой, пустое состояние - 3.2 Ссылка на страницу в
web/templates/partials/header.html, активный пункт навигации - 3.3 Обработчик
GET /delete: сборка представления, безhx-*самообновления - 3.4 Тест: подставной читатель отдаёт задачи во всех состояниях — в
разметке есть строки только у
done/orphaned/target_missing - 3.5 Тест: в разметке страницы нет
hx-trigger="every - 3.6 Тест: страница называет предел размера пачки
- 3.7 Тест: пустое состояние — нет разрешённых, удаление не предлагается
4. Разбор входа (общий для обеих границ)
- 4.1 Общая функция разбора пачки:
ident.Parseпо каждому идентификатору с отказом всего запроса, схлопывание дублей, проверка предела, отказ на пустом наборе. Предел — именованная константа, значение 20 - 4.2 Обработчик
POST /ui/delete/confirm: разбор через общую функцию, чтение выбранных загрузок; ненайденная запись — строка «загрузка не найдена», а не молчаливый пропуск - 4.3 Отказ по пределу возвращает страницу выбора с сохранёнными отметками и объяснением
- 4.4 Шаблон страницы подтверждения: каждая выбранная загрузка названа
заголовком, идентификатором и состоянием; для
orphaned— отметка «источник пропал, библиотечная ссылка — последняя копия данных»; скрытые поля с идентификаторами, признак подтверждения, кнопка исполнения и ссылка возврата - 4.5 Тест: страница подтверждения содержит заголовки всех выбранных и не
делает ни одного вызова
Delete - 4.6 Тест: строка
orphanedна подтверждении несёт отметку о последней копии - 4.7 Тест: пачка сверх предела отклоняется целиком,
Deleteне зовётся, а ответ несёт страницу выбора с сохранёнными отметками - 4.8 Тест: неразобранный идентификатор отклоняет запрос целиком
- 4.9 Тест: пустой выбор — подтверждение не показывается,
Deleteне зовётся - 4.10 Тест: идентификатор без записи назван строкой на подтверждении и в отчёте, остальные загрузки удалены
5. Исполнение и отчёт
- 5.1 Обработчик
POST /ui/delete: отказ без признака подтверждения до цикла, ни одного вызоваDelete - 5.2 Тот же разбор входа, что и на подтверждении (пункт 4.1): исполняющий запрос — самостоятельная входная граница
- 5.3 Последовательный вызов
Reviewer.Deleteпо каждой подтверждённой загрузке, сбор исхода по каждой; контекст исполнения отвязан отr.Context()(context.WithoutCancel); ошибка — через публичный канал (userErr) - 5.3a Потолок времени на проход (
bulkBudget, 2 минуты), проверяемый между единицами: начатое удаление не обрывается. Остаток — в отчёт строками «не выполнено» - 5.4 Остановка прохода после трёх подряд отказов внешнего сервиса
(
ErrConflictсистемным не считается и счётчик не двигает; успех счётчик сбрасывает); остаток пачки — в отчёт строками «не выполнено» с причиной остановки. Порог — именованная константа - 5.5 Шаблон страницы результата: удалённые поимённо, отказавшие поимённо с
причиной, невыполненный остаток и причина остановки, ссылка обратно на
/delete - 5.6 Тест: POST без признака подтверждения — отказ, ноль вызовов
Delete - 5.7 Тест: гарды исполняющего запроса не слабее гардов подтверждения —
неразобранный идентификатор, пачка сверх предела и пустой набор отклоняются с
признаком подтверждения тоже,
Deleteне зовётся - 5.8 Тест: второй
Deleteвозвращает ошибку — первая и третья удалены, страница результата называет вторую и её причину - 5.9 Тест:
DeleteдляdownloadingвозвращаетErrConflict— страница показывает отказ по ней, остальные выбранные удалены - 5.10 Тест: все удалены — отчёт называет обе как удалённые, отказов нет
- 5.11 Тест: отмена запроса не прекращает пачку —
Deleteвызван по всем подтверждённым - 5.12 Тест: сырой текст ошибки внешнего сервиса в разметку не попадает
- 5.13 Тест: страница и действия работают без htmx (обычные POST-формы,
actionрабочий)
6. Сдача
- 6.1
openspec validate --strict bulk-delete-page - 6.2
task gateзелёный - 6.3 Поведенческая проверка вживую: поднять
task run, пройти путь список → подтверждение → результат - 6.4 Три числовых порога (размер пачки, число подряд идущих отказов,
потолок времени на проход) — в
docs/database.md, раздел настроек с числовым значением
Критерии приёмки (из постановки)
- П1 Страница открывается из шапки и показывает только те загрузки, для
которых удаление разрешено поштучно (оракул: тест
internal/httpapi— подставной читатель отдаёт задачи во всех состояниях, в разметке строки есть уdone/orphaned/target_missingи нет у остальных) - П2 Удаление уходит только после явного подтверждения, и подтверждение
называет каждую выбранную раздачу поимённо (оракул: тест — POST без признака
подтверждения отвечает отказом и не делает ни одного вызова
Deleteу подставного воркера; ответ подтверждения содержит заголовки всех выбранных) - П3 Отказ на одной загрузке не отменяет остальных, а результат называет
удалённые и отказавшие поимённо с причиной (оракул: тест, где второй
Deleteвозвращает ошибку — первая и третья удалены, страница результата называет вторую и её причину) - П4 Групповой путь не расширяет прав поштучного (оракул: тест —
DeleteдляdownloadingвозвращаетErrConflict, страница показывает отказ, остальные выбранные не затронуты) - П5 Поведение страницы записано дельта-спекой и проходит валидацию
(оракул:
openspec validate --strictиtask gate)
Приёмочные критерии из рубрики ревью дизайна
- Р1 Исполняется ровно подтверждённое множество, названное списком идентификаторов, а не предикатом «всё, что сейчас в состоянии X» (оракул: тест — задача, ставшая разрешённой после показа подтверждения, не удаляется)
- Р2 Множество допустимых состояний имеет единственный дом, и групповой путь прав не расширяет (оракул: пункты 1.5, 1.6, 5.9)
- Р3 Экран подтверждения называет не только предмет, но и последствие —
состояние и снятие последней копии для
orphaned(оракул: пункт 4.6) - Р4 Вход валидируется целиком до первого эффекта, на обеих границах (оракул: пункты 4.7–4.9, 5.7)
- Р5 Частичный отказ не отменяет остальных, повтор идемпотентен (оракул:
пункты 5.8, 5.9; повтор — уже
deleted,CanDeleteложно) - Р6 Обрыв или отмена запроса не оставляет пачку на середине, исход каждой
единицы переживает потерю ответа (оракул: пункт 5.11 и
logCmdпо каждому вызову) - Р7 Предел пачки виден там, где формируется вход, и отказ по нему не стирает выбор (оракул: пункты 3.6, 4.7)
- Р8 Транспорт доменной логики не содержит: зовётся тот же
Reviewer.Delete, переход состояния — в воркере (оракул: чтение диффа на ревью кода)