приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+63
View File
@@ -1,7 +1,9 @@
package entity
import (
"strings"
"time"
"unicode"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
@@ -62,6 +64,33 @@ type AudioRecord struct {
Title *string
Brief *string
// OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от
// заголовка**: заголовок несёт название, которое дал человек либо посчитала
// языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока
// заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы
// имя, и вернуть затёртое было бы неоткуда.
//
// Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает
// управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант
// приватности.
OriginalFilename *string
// DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма.
// Со строкой файла они намеренно не сверяются: там лежат величины той копии,
// которой файл является сейчас, и уточнение длительности меняет их, не трогая
// эти. Нужны колонками записи, потому что показываются в списке.
//
// Указатели здесь не выражают «неизвестно»: числовая колонка хранилища
// пустого значения не держит, и пустое кладётся нулём. Обе величины ставит
// приём и ставит всегда — запись с непрочитанными метаданными отвергается
// отказом и не заводится вовсе. Решение владельца 2026-08-15.
DurationMs *int64
SizeBytes *int64
// TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено
// вперёд, заполняет его задача, считающая темы языковой моделью.
TopicIDs []string
State string
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
// Откладывание опроса его не двигает — иначе застревание в чужой операции
@@ -99,6 +128,40 @@ type AudioRecord struct {
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)
}
// AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string {
out := make([]string, 0, len(stages))