- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
237 lines
13 KiB
Go
237 lines
13 KiB
Go
package entity
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
)
|
|
|
|
// Рубежи конвейера. Рубеж называет **достигнутое**, а не предстоящее: по нему
|
|
// видно, что с записью уже сделано, и потому остановленная запись продолжает с
|
|
// места остановки, а не с начала.
|
|
//
|
|
// Конечный рубеж зовётся `done`: доставка ответа отправителю в конвейер не
|
|
// входит, и слово описывает пройденный конвейер, а не полученный человеком
|
|
// текст.
|
|
const (
|
|
StateUploaded = "uploaded"
|
|
StateNormalized = "normalized"
|
|
StateSubmitted = "submitted"
|
|
StateTranscribed = "transcribed"
|
|
StateDone = "done"
|
|
)
|
|
|
|
// Причины остановки. Прежние состояния `failed` и `dead` схлопнуты сюда: обе
|
|
// восстанавливаются одинаково — снятием признака, — и различие между ними
|
|
// перестало быть структурным.
|
|
const (
|
|
// HaltReasonStepFailed — шаг рассудил об этой записи окончательно.
|
|
HaltReasonStepFailed = "step_failed"
|
|
// HaltReasonAttempts — мы повторяли и перестали.
|
|
HaltReasonAttempts = "attempts_exhausted"
|
|
// HaltReasonStuck — запись простояла в рубеже дольше предела.
|
|
HaltReasonStuck = "stuck"
|
|
)
|
|
|
|
// AudioRecord — аудиозапись, центральная сущность сервиса.
|
|
//
|
|
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
|
|
// попытка распознавания — живут своими строками и адресуются ссылками. Поля
|
|
// очереди соседствуют с доменом, но не с содержимым: расшифровка лежит строкой
|
|
// `texts`, и чтение очереди её не тянет.
|
|
type AudioRecord struct {
|
|
Id string
|
|
// OwnerID — учётная запись, от имени которой запись принята. Обязателен:
|
|
// колонка владельца пустого значения не принимает, и ничьей записи в
|
|
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
|
// меняется.
|
|
OwnerID string
|
|
|
|
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
|
// лежат колонками записи, а не строками `texts`.
|
|
Title *string
|
|
Brief *string
|
|
|
|
// OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от
|
|
// заголовка**: заголовок несёт название, которое дал человек либо посчитала
|
|
// языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока
|
|
// заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы
|
|
// имя, и вернуть затёртое было бы неоткуда.
|
|
//
|
|
// Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает
|
|
// управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант
|
|
// приватности.
|
|
OriginalFilename *string
|
|
|
|
// DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма.
|
|
// Со строкой файла они намеренно не сверяются: там лежат величины той копии,
|
|
// которой файл является сейчас, и уточнение длительности меняет их, не трогая
|
|
// эти. Нужны колонками записи, потому что показываются в списке.
|
|
//
|
|
// Указатели здесь не выражают «неизвестно»: числовая колонка хранилища
|
|
// пустого значения не держит, и пустое кладётся нулём. Обе величины ставит
|
|
// приём и ставит всегда — запись с непрочитанными метаданными отвергается
|
|
// отказом и не заводится вовсе. Решение владельца 2026-08-15.
|
|
DurationMs *int64
|
|
SizeBytes *int64
|
|
|
|
// TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено
|
|
// вперёд, заполняет его задача, считающая темы языковой моделью.
|
|
TopicIDs []string
|
|
|
|
State string
|
|
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
|
|
// Откладывание опроса его не двигает — иначе застревание в чужой операции
|
|
// не наступало бы никогда.
|
|
StateEnteredAt time.Time
|
|
|
|
// Остановка — признак, а не рубеж: `State` при ней не стирается.
|
|
HaltedAt *time.Time
|
|
HaltReason *string
|
|
ErrorText *string
|
|
|
|
// AcquisitionID — признак **этого** захвата, значение уникальное для каждого.
|
|
// Запись результата условна по нему, а не по занятости записи: захват,
|
|
// перевыданный другому — по протуханию срока или после снятия остановки
|
|
// человеком, — обязан обратить запись первого в отказ.
|
|
AcquisitionID *string
|
|
AcquireExpiresAt *time.Time
|
|
DelayTime *time.Time
|
|
// Attempts считает **отказы** и ограничивает повторы внутри шага. Время в
|
|
// рубеже мерит StateEnteredAt: одно число не справлялось ни с одной из двух
|
|
// обязанностей.
|
|
Attempts int
|
|
|
|
// Ссылки на файлы живут порознь и не переставляются: исходник остаётся
|
|
// доступным после того, как запись прошла конвейер.
|
|
OriginalFileID *string
|
|
NormalizedFileID *string
|
|
|
|
StructureID *string
|
|
TranscriptTextID *string
|
|
LiteraryTextID *string
|
|
RecognitionID *string
|
|
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// MaxOriginalFilenameLen — потолок длины имени файла, данного отправителем.
|
|
//
|
|
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому длина
|
|
// назначается сервисом. Число выведено из предела длины имени в распространённых
|
|
// файловых системах: имя длиннее 255 знаков не приходит от системного диалога
|
|
// выбора файла вовсе, и всё, что длиннее, — либо самодельный запрос, либо
|
|
// попытка раздуть строку записи.
|
|
const MaxOriginalFilenameLen = 255
|
|
|
|
// SanitizeOriginalFilename приводит имя, данное отправителем, к пригодному для
|
|
// хранения виду: убирает управляющие знаки и режет по потолку длины.
|
|
//
|
|
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
|
|
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
|
|
// иначе доезжают до экрана; резка идёт **после** уборки,
|
|
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
|
|
//
|
|
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
|
|
// по байтам разрубила бы знак пополам.
|
|
func SanitizeOriginalFilename(name string) string {
|
|
cleaned := strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, name)
|
|
|
|
runes := []rune(cleaned)
|
|
if len(runes) > MaxOriginalFilenameLen {
|
|
runes = runes[:MaxOriginalFilenameLen]
|
|
}
|
|
return string(runes)
|
|
}
|
|
|
|
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
|
|
//
|
|
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
|
|
// Число отказов обнуляется — шаг, дошедший до перехода, завершился без отказа, а
|
|
// отказы считают именно отказавшие: иначе запись, прошедшая конвейер целиком,
|
|
// накопила бы их поштучно и остановилась бы здоровой.
|
|
func (r *AudioRecord) MoveToState(state string) {
|
|
now := clock.Now()
|
|
r.State = state
|
|
r.StateEnteredAt = now
|
|
r.DelayTime = nil
|
|
r.AcquisitionID = nil
|
|
r.AcquireExpiresAt = nil
|
|
r.Attempts = 0
|
|
r.UpdatedAt = now
|
|
}
|
|
|
|
// Postpone откладывает работу над записью: ставит паузу и снимает захват.
|
|
//
|
|
// Переходом это не является и потому не трогает ни рубеж, ни время входа в
|
|
// него. Число отказов обнуляется по прежнему доводу — ожидание чужой операции
|
|
// отказом не является.
|
|
//
|
|
// Прежде шаг опроса звал переход с **тем же** состоянием, и мнимость этого
|
|
// перехода обнуляла сторожа. Без разделения время входа в рубеж сбрасывалось бы
|
|
// на каждом опросе и повторило бы ровно тот промах, ради которого заводится.
|
|
func (r *AudioRecord) Postpone(until time.Time) {
|
|
r.DelayTime = &until
|
|
r.AcquisitionID = nil
|
|
r.AcquireExpiresAt = nil
|
|
r.Attempts = 0
|
|
r.UpdatedAt = clock.Now()
|
|
}
|
|
|
|
// RetryAfter освобождает отказавшую запись для повтора: захват снимается, пауза
|
|
// ставится, а число отказов сохраняется — по нему растёт пауза и наступает
|
|
// предел.
|
|
func (r *AudioRecord) RetryAfter(delay time.Time) {
|
|
r.AcquisitionID = nil
|
|
r.AcquireExpiresAt = nil
|
|
r.DelayTime = &delay
|
|
r.UpdatedAt = clock.Now()
|
|
}
|
|
|
|
// Halt останавливает запись признаком, сохраняя достигнутый рубеж.
|
|
//
|
|
// Число отказов сохраняется: по нему видно, сколько раз пробовали. Захват
|
|
// снимается — остановленная запись всё равно не выдаётся, а оставленный признак
|
|
// захвата помешал бы первому же захвату после снятия остановки.
|
|
func (r *AudioRecord) Halt(reason, errText string) {
|
|
now := clock.Now()
|
|
r.HaltedAt = &now
|
|
r.HaltReason = &reason
|
|
r.ErrorText = &errText
|
|
r.AcquisitionID = nil
|
|
r.AcquireExpiresAt = nil
|
|
r.DelayTime = nil
|
|
r.UpdatedAt = now
|
|
}
|
|
|
|
// Resume возвращает остановленную запись в работу с сохранённого рубежа.
|
|
//
|
|
// Сбрасываются все три сторожа. Время входа в рубеж — тоже, и это не
|
|
// избыточность: запись, простоявшая остановленной дольше предела, иначе
|
|
// останавливалась бы снова первым же захватом, и перезапуск не работал бы вовсе.
|
|
func (r *AudioRecord) Resume() {
|
|
now := clock.Now()
|
|
r.HaltedAt = nil
|
|
r.HaltReason = nil
|
|
r.ErrorText = nil
|
|
r.Attempts = 0
|
|
r.DelayTime = nil
|
|
r.AcquisitionID = nil
|
|
r.AcquireExpiresAt = nil
|
|
r.StateEnteredAt = now
|
|
r.UpdatedAt = now
|
|
}
|
|
|
|
// IsHalted — стоит ли на записи признак остановки.
|
|
func (r *AudioRecord) IsHalted() bool {
|
|
return r.HaltedAt != nil
|
|
}
|