- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
174 lines
8.2 KiB
Go
174 lines
8.2 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
)
|
|
|
|
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
|
|
//
|
|
// Работает оно **по имени колонки**: именованные параметры запроса и место
|
|
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
|
|
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
|
|
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
|
|
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
|
|
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
|
|
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
|
|
// подмена значения.
|
|
//
|
|
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
|
|
// можно забыть в отображении или в шаге схемы, и сверку держат правила
|
|
// `internal/archrules`.
|
|
|
|
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
|
|
//
|
|
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
|
|
// своего сохранения, а это часы. Всё, что владелец правил за это время,
|
|
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
|
|
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
|
|
// отправителя, длительность, размер и темы не трогаются вовсе.
|
|
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
|
|
return map[string]any{
|
|
"state": r.State,
|
|
"state_entered_at": formatTime(r.StateEnteredAt),
|
|
"halted_at": timeValue(r.HaltedAt),
|
|
"halt_reason": stringValue(r.HaltReason),
|
|
"error_text": stringValue(r.ErrorText),
|
|
"acquisition_id": stringValue(r.AcquisitionID),
|
|
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
|
|
"delay_time": timeValue(r.DelayTime),
|
|
"attempts": r.Attempts,
|
|
"original_file_id": stringValue(r.OriginalFileID),
|
|
"normalized_file_id": stringValue(r.NormalizedFileID),
|
|
"transcript_text_id": stringValue(r.TranscriptTextID),
|
|
"literary_text_id": stringValue(r.LiteraryTextID),
|
|
"structure_id": stringValue(r.StructureID),
|
|
"recognition_id": stringValue(r.RecognitionID),
|
|
"updated_at": formatTime(r.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
|
|
//
|
|
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
|
|
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
|
|
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
|
|
// молча.
|
|
func writeRecord(r *entity.AudioRecord) map[string]any {
|
|
values := writeOwnedByPipeline(r)
|
|
|
|
own := map[string]any{
|
|
"id": r.Id,
|
|
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
|
|
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
|
|
// шага, записанный поверх, стёр бы его молча.
|
|
"owner_id": r.OwnerID,
|
|
"title": stringValue(r.Title),
|
|
"brief": stringValue(r.Brief),
|
|
// Имя файла отправителя, длительность и размер кладёт приём и только он:
|
|
// это снимок принятого, и конвейер его не пересчитывает.
|
|
"original_filename": stringValue(r.OriginalFilename),
|
|
"duration_ms": numberValue(r.DurationMs),
|
|
"size_bytes": numberValue(r.SizeBytes),
|
|
"created_at": formatTime(r.CreatedAt),
|
|
}
|
|
for name, value := range own {
|
|
values[name] = value
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// recordRow — сырые значения одной строки аудиозаписи.
|
|
type recordRow struct {
|
|
id string
|
|
ownerID string
|
|
title sql.NullString
|
|
brief sql.NullString
|
|
originalFilename sql.NullString
|
|
durationMs int64
|
|
sizeBytes int64
|
|
state string
|
|
stateEnteredAt string
|
|
haltedAt sql.NullString
|
|
haltReason sql.NullString
|
|
errorText sql.NullString
|
|
acquisitionID sql.NullString
|
|
acquireExpiresAt sql.NullString
|
|
delayTime sql.NullString
|
|
attempts int
|
|
originalFileID sql.NullString
|
|
normalizedFileID sql.NullString
|
|
transcriptTextID sql.NullString
|
|
literaryTextID sql.NullString
|
|
structureID sql.NullString
|
|
recognitionID sql.NullString
|
|
createdAt string
|
|
updatedAt string
|
|
}
|
|
|
|
// readRecordColumns — куда кладётся каждая колонка при чтении.
|
|
//
|
|
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
|
|
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
|
|
func readRecordColumns(row *recordRow) map[string]any {
|
|
return map[string]any{
|
|
"id": &row.id,
|
|
"owner_id": &row.ownerID,
|
|
"title": &row.title,
|
|
"brief": &row.brief,
|
|
"original_filename": &row.originalFilename,
|
|
"duration_ms": &row.durationMs,
|
|
"size_bytes": &row.sizeBytes,
|
|
"state": &row.state,
|
|
"state_entered_at": &row.stateEnteredAt,
|
|
"halted_at": &row.haltedAt,
|
|
"halt_reason": &row.haltReason,
|
|
"error_text": &row.errorText,
|
|
"acquisition_id": &row.acquisitionID,
|
|
"acquire_expires_at": &row.acquireExpiresAt,
|
|
"delay_time": &row.delayTime,
|
|
"attempts": &row.attempts,
|
|
"original_file_id": &row.originalFileID,
|
|
"normalized_file_id": &row.normalizedFileID,
|
|
"transcript_text_id": &row.transcriptTextID,
|
|
"literary_text_id": &row.literaryTextID,
|
|
"structure_id": &row.structureID,
|
|
"recognition_id": &row.recognitionID,
|
|
"created_at": &row.createdAt,
|
|
"updated_at": &row.updatedAt,
|
|
}
|
|
}
|
|
|
|
// rowToAudioRecord собирает доменную запись из прочитанной строки.
|
|
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
|
|
return &entity.AudioRecord{
|
|
Id: row.id,
|
|
OwnerID: row.ownerID,
|
|
Title: stringOf(row.title),
|
|
Brief: stringOf(row.brief),
|
|
OriginalFilename: stringOf(row.originalFilename),
|
|
DurationMs: numberOf(row.durationMs),
|
|
SizeBytes: numberOf(row.sizeBytes),
|
|
State: row.state,
|
|
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
|
|
HaltedAt: timeOf(row.haltedAt),
|
|
HaltReason: stringOf(row.haltReason),
|
|
ErrorText: stringOf(row.errorText),
|
|
AcquisitionID: stringOf(row.acquisitionID),
|
|
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
|
|
DelayTime: timeOf(row.delayTime),
|
|
Attempts: row.attempts,
|
|
OriginalFileID: stringOf(row.originalFileID),
|
|
NormalizedFileID: stringOf(row.normalizedFileID),
|
|
TranscriptTextID: stringOf(row.transcriptTextID),
|
|
LiteraryTextID: stringOf(row.literaryTextID),
|
|
StructureID: stringOf(row.structureID),
|
|
RecognitionID: stringOf(row.recognitionID),
|
|
TopicIDs: []string{},
|
|
CreatedAt: requiredTimeOf(row.createdAt),
|
|
UpdatedAt: requiredTimeOf(row.updatedAt),
|
|
}
|
|
}
|