Files
transcriber/internal/adapter/repo/pocketbase/owner_guard.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

94 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pocketbase
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
// аудиозаписи, их файлы либо темы её словаря.
//
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
// никому — архив человека исчез бы молча и восстановлению не подлежал:
// прежнего владельца не остаётся нигде.
//
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
//
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
// учётную запись запросом, так что страж закрывает и публичную поверхность.
//
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
// аудио становилось бы ничьим.
func GuardOwnerDeletion(app core.App) {
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
count, err := countOwned(e.App, e.Record.Id)
if err != nil {
return err
}
if count > 0 {
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
// наружу только `*router.ApiError`, а всякую другую подменяет своим
// сообщением — «убедитесь, что запись не участвует в обязательной
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
// единственная обязательная связь у задачи — файл, и владелец панели,
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
// ровно то необратимое, ради предотвращения чего страж и заведён.
//
// Число в отказе — не содержимое записей, а их счёт: он говорит
// владельцу панели, почему удаление не прошло, и не выносит наружу
// ничего о самих записях.
return router.NewBadRequestError(fmt.Sprintf(
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
count,
), nil)
}
return e.Next()
})
}
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
// панели пойдёт удалять записи руками.
//
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
var ownedCollections = []string{
migrations.RecordsCollection,
migrations.FilesCollection,
migrations.TopicsCollection,
}
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
// с колонкой владельца.
func countOwned(app core.App, ownerID string) (int64, error) {
var total int64
for _, collection := range ownedCollections {
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
if err != nil {
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
}
total += count
}
return total, nil
}