Files
transcriber/internal/adapter/repo/pocketbase/text_repo.go
T
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00

171 lines
6.7 KiB
Go

package pocketbase
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type TextRepository struct {
app core.App
}
func NewTextRepository(app core.App) *TextRepository {
return &TextRepository{app: app}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
// результата читалось бы как недосмотр.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
collection, err := findCollection(repo.app, migrations.TextsCollection)
if err != nil {
return nil, err
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.TextsCollection,
"record = {:record} && kind = {:kind}",
dbx.Params{"record": recordID, "kind": kind},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое.
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("kind", kind)
default:
// Отказ хранилища «строкой нет» не является, и подменять его вставкой
// нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на
// запись вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && record.GetString("contents") != "" {
return textFromRecord(record), nil
}
record.Set("contents", contents)
if err := repo.app.Save(record); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// хранилища несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
return textFromRecord(record), nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
record, err := repo.app.FindRecordById(migrations.TextsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return textFromRecord(record), nil
}
func textFromRecord(record *core.Record) *entity.Text {
return &entity.Text{
Id: record.Id,
RecordID: record.GetString("record"),
Kind: record.GetString("kind"),
Contents: record.GetString("contents"),
}
}
type StructureRepository struct {
app core.App
}
func NewStructureRepository(app core.App) *StructureRepository {
return &StructureRepository{app: app}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
collection, err := findCollection(repo.app, migrations.StructuresCollection)
if err != nil {
return nil, err
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.StructuresCollection,
"record = {:record} && version = {:version}",
dbx.Params{"record": recordID, "version": version},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
// непустого не кладётся по тому же доводу, что и у текста: повторный
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
return repo.GetByID(record.Id)
}
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("version", version)
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
record.Set("contents", string(contents))
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
return &entity.Structure{
Id: record.Id,
RecordID: recordID,
Version: version,
Replicas: replicas,
}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
record, err := repo.app.FindRecordById(migrations.StructuresCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
var replicas []entity.Replica
raw := record.GetString("contents")
if raw != "" {
if err := json.Unmarshal([]byte(raw), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
}
return &entity.Structure{
Id: record.Id,
RecordID: record.GetString("record"),
Version: record.GetInt("version"),
Replicas: replicas,
}, nil
}