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

- адреса приложения переехали в своё пространство `/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))
+69
View File
@@ -0,0 +1,69 @@
package entity
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// Имя файла приходит извне и содержимым своим приёму не подконтрольно. Правило
// чистки живёт в домене, а не в транспорте: имя доходит до колонки записи одним
// путём, и правил обязано быть одно.
func TestSanitizeOriginalFilename(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "обычное имя не трогается",
in: "разговор.mp3",
want: "разговор.mp3",
},
{
name: "управляющие знаки убираются",
in: "разго\x00вор\x07\x1b.mp3",
want: "разговор.mp3",
},
{
name: "перевод строки — тоже управляющий знак",
in: "первая\nвторая.mp3",
want: "перваявторая.mp3",
},
{
name: "пустое остаётся пустым",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, SanitizeOriginalFilename(tc.in))
})
}
}
// Режется имя по знакам, а не по байтам: имя русское чаще, чем латинское, и
// обрезка по байтам разрубила бы знак пополам.
func TestSanitizeOriginalFilenameTrimsByRunes(t *testing.T) {
long := strings.Repeat("я", MaxOriginalFilenameLen+50)
got := SanitizeOriginalFilename(long)
assert.Len(t, []rune(got), MaxOriginalFilenameLen, "длина считается знаками")
assert.True(t, strings.HasPrefix(long, got), "обрезано с хвоста, а не переписано")
assert.Equal(t, long[:len(got)], got, "ни один знак не разрублен пополам")
}
// Уборка идёт до резки: иначе потолок съедали бы знаки, которых в сохранённом
// имени всё равно не будет.
func TestSanitizeOriginalFilenameCleansBeforeTrimming(t *testing.T) {
dirty := strings.Repeat("\x00", 100) + strings.Repeat("я", MaxOriginalFilenameLen)
got := SanitizeOriginalFilename(dirty)
assert.Len(t, []rune(got), MaxOriginalFilenameLen,
"сто управляющих знаков не откусили сто знаков имени")
}
+53
View File
@@ -76,6 +76,59 @@ func StageByName(name string) (Stage, bool) {
return Stage{}, false
}
// ListFilter — состояние записи, по которому её отбирает список приложения.
//
// Состояний три, а не два, и это не педантизм. Остановленная запись не в работе
// и не завершена: при отборе надвое она выпала бы из обеих половин — исчезла бы
// из списка при любом значении отбора, — хотя ради неё человек список и
// открывает.
type ListFilter string
const (
// ListFilterWorking — запись идёт по конвейеру.
ListFilterWorking ListFilter = "working"
// ListFilterHalted — запись остановлена признаком.
ListFilterHalted ListFilter = "halted"
// ListFilterDone — запись прошла конвейер.
ListFilterDone ListFilter = "done"
)
// ParseListFilter узнаёт состояние отбора по его имени. Второе значение ложно у
// имени, которого в перечне нет: такой отбор — негодный ввод, а не пустая
// выборка.
func ParseListFilter(v string) (ListFilter, bool) {
switch ListFilter(v) {
case ListFilterWorking, ListFilterHalted, ListFilterDone:
return ListFilter(v), true
}
return "", false
}
// TerminalStages — рубежи, из которых запись в работу не берут.
//
// Выводится из дескриптора наравне с WorkingStages: отбор списка — очередной
// потребитель словаря рубежей, и перечислять их у него строкой запроса нельзя.
// Рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх
// состояний отбора.
func TerminalStages() []Stage {
out := make([]Stage, 0, len(stages))
for _, s := range stages {
if s.Terminal {
out = append(out, s)
}
}
return out
}
// StageNames разворачивает рубежи в их имена — для запроса к хранилищу.
func StageNames(list []Stage) []string {
out := make([]string, 0, len(list))
for _, s := range list {
out = append(out, s.Name)
}
return out
}
// StuckLimits — пределы простоя, приходящие из настроек.
type StuckLimits struct {
// Own — предел на своей работе.
+30
View File
@@ -12,6 +12,36 @@ const (
TextKindLiterary = "literary"
)
// Виды текста, которыми приложение спрашивает текст записи.
//
// Перечень закрыт, и каждое значение называет ровно одну хранимую вещь. Назван
// он так, а не парой «вид текста плюс форма показа», потому что реплики со
// временем — не вид текста: они лежат структурой разбора и принадлежат записи, а
// не тексту. Пара из двух параметров обещала бы сочетания, которых не существует.
//
// Вычитанный текст назван здесь вперёд, хотя считает его отдельная задача:
// перечень без него пришлось бы расширять правкой публичного контракта — того
// самого, который согласован один раз.
const (
// TextViewTranscript — сырая расшифровка сплошным текстом.
TextViewTranscript = TextKindTranscript
// TextViewLiterary — вычитанный текст сплошным.
TextViewLiterary = TextKindLiterary
// TextViewReplicas — реплики со временем.
TextViewReplicas = "replicas"
)
// IsKnownTextView — принадлежит ли вид закрытому перечню. Незаданный вид
// известным не считается: умолчание сделало бы ответ функцией того, что успел
// записать конвейер, а не состояния записи.
func IsKnownTextView(view string) bool {
switch view {
case TextViewTranscript, TextViewLiterary, TextViewReplicas:
return true
}
return false
}
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
func AllTextKinds() []string {
return []string{TextKindTranscript, TextKindLiterary}