хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -35,15 +35,6 @@ const (
|
||||
HaltReasonStuck = "stuck"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceUnknown = "unknown"
|
||||
SourceApi = "api"
|
||||
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
|
||||
// с этим источником не появляется, а константа остаётся: на неё ссылается
|
||||
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
|
||||
SourceTelegram = "telegram"
|
||||
)
|
||||
|
||||
// AudioRecord — аудиозапись, центральная сущность сервиса.
|
||||
//
|
||||
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
|
||||
@@ -57,7 +48,6 @@ type AudioRecord struct {
|
||||
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
||||
// меняется.
|
||||
OwnerID string
|
||||
Source string
|
||||
|
||||
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
||||
// лежат колонками записи, а не строками `texts`.
|
||||
@@ -142,7 +132,7 @@ const MaxOriginalFilenameLen = 255
|
||||
//
|
||||
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
|
||||
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
|
||||
// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки,
|
||||
// иначе доезжают до экрана; резка идёт **после** уборки,
|
||||
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
|
||||
//
|
||||
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
|
||||
@@ -162,20 +152,6 @@ func SanitizeOriginalFilename(name string) string {
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// AllStates — закрытый перечень рубежей для схемы хранилища.
|
||||
func AllStates() []string {
|
||||
out := make([]string, 0, len(stages))
|
||||
for _, s := range stages {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
|
||||
func AllHaltReasons() []string {
|
||||
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
|
||||
}
|
||||
|
||||
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
|
||||
//
|
||||
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
|
||||
|
||||
+11
-18
@@ -4,21 +4,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
|
||||
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
|
||||
// разным вещам запись о файле и хранилище, в котором она лежит.
|
||||
const (
|
||||
LocationLocal = "local"
|
||||
LocationS3 = "s3"
|
||||
)
|
||||
|
||||
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
|
||||
// потолка записи в шесть часов с запасом на видео, а не из замера.
|
||||
//
|
||||
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
|
||||
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
|
||||
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
|
||||
// дойдёт до обработчика — без строки в журнале приёма.
|
||||
// Число называется **явно** везде, где иначе действует умолчание: у тела запроса
|
||||
// приёма умолчание отсекало бы запись раньше, чем она дойдёт до обработчика — и
|
||||
// без строки в журнале приёма. Умолчания здесь не «без предела», а величины на
|
||||
// два-три порядка меньше нужного.
|
||||
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||
|
||||
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
|
||||
@@ -26,12 +18,13 @@ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||
// существует только потому, что провайдер читает аудио по адресу, и её ключ
|
||||
// живёт в строке попытки распознавания.
|
||||
type File struct {
|
||||
Id string
|
||||
Location string
|
||||
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
|
||||
// хранилище к заданному имени не дописывает: суффикс появляется только у
|
||||
// имён, которые оно строит само из имени отправителя, а это умолчание не
|
||||
// применяется.
|
||||
Id string
|
||||
// RecordID — запись, которой копия принадлежит. Копии одной записи лежат её
|
||||
// подкаталогом, названным этим идентификатором: так они лежат вместе, а
|
||||
// запись убирается целиком одним движением.
|
||||
RecordID string
|
||||
// FileName — имя, под которым файл лежит: имя задаёт сервис. Имя, данное
|
||||
// отправителем, в него не попадает — от него взято только расширение.
|
||||
FileName string
|
||||
Size int64
|
||||
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MaxProviderLoginLength — предел длины логина у провайдера.
|
||||
//
|
||||
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
|
||||
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
|
||||
// же, что у имени: длиннее имени логин не бывает, а два разных предела на
|
||||
// соседних колонках одной записи разошлись бы молча.
|
||||
const MaxProviderLoginLength = 255
|
||||
|
||||
// MaxDisplayNameLength — предел длины имени, пригодного к показу.
|
||||
const MaxDisplayNameLength = 255
|
||||
|
||||
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
|
||||
// отвергает его.
|
||||
//
|
||||
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
|
||||
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
|
||||
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
|
||||
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
//
|
||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||
// называет одного человека, а две записи о нём разошлись бы молча. Сравнение при
|
||||
// поиске остаётся точным, знак в знак: приведение регистра завело бы правило,
|
||||
// которого у провайдера нет.
|
||||
//
|
||||
// Живёт в домене, а не в транспорте и не в хранилище: правило одно на все
|
||||
// способы представиться, а уложенное куском в слой оно разошлось бы двумя
|
||||
// копиями при первом же втором способе.
|
||||
func AcceptProviderLogin(value string) (string, bool) {
|
||||
login := strings.TrimSpace(value)
|
||||
|
||||
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и два
|
||||
// предела в разных единицах разошлись бы вдвое на любой кириллице.
|
||||
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, r := range login {
|
||||
if unicode.IsControl(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
return login, true
|
||||
}
|
||||
|
||||
// AcceptDisplayName приводит имя к годному для колонки значению.
|
||||
//
|
||||
// Обрезается по пределу и чистится от управляющих знаков — тем же приёмом, каким
|
||||
// приём записи чистит имя файла отправителя. Пустое значение законно: имени у
|
||||
// человека может не быть вовсе, и негодное значение необязательного поля не
|
||||
// вправе отменять заведение учётной записи.
|
||||
func AcceptDisplayName(value string) string {
|
||||
name := strings.TrimSpace(stripControls(value))
|
||||
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxDisplayNameLength {
|
||||
return string(runes[:MaxDisplayNameLength])
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// AcceptEmail отдаёт адрес почты, если он вообще похож на адрес.
|
||||
//
|
||||
// Негодный отбрасывается здесь, а не отказом сохранения: иначе опечатка в
|
||||
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью «почта
|
||||
// занята», и владелец искал бы общий ящик там, где сломан контур.
|
||||
func AcceptEmail(value string) (string, bool) {
|
||||
email := strings.TrimSpace(value)
|
||||
if email == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Разбор адреса принимает и форму «Имя <адрес>»: такую строку заголовок
|
||||
// приносить не должен, и адресом она не считается.
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Name != "" || parsed.Address != email {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return email, true
|
||||
}
|
||||
|
||||
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
|
||||
// им делать нечего.
|
||||
func stripControls(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
@@ -87,10 +87,11 @@ func (r *RecognitionResult) GetError() string {
|
||||
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
|
||||
// сущность вовсе.
|
||||
//
|
||||
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает
|
||||
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
||||
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому,
|
||||
// что результат операции у провайдера не переспрашивается.
|
||||
// Сохранённый ответ провайдера лежит **третьим файлом в подкаталоге записи**, а
|
||||
// не колонкой этой строки: шаг опроса читает её раз в несколько секунд, а
|
||||
// репозиторий читает строку целиком — ответ на многочасовую запись ехал бы в
|
||||
// память при каждом опросе. Хранится он потому, что результат операции у
|
||||
// провайдера не переспрашивается.
|
||||
type Recognition struct {
|
||||
Id string
|
||||
RecordID string
|
||||
@@ -115,6 +116,6 @@ type RecognitionOutcome struct {
|
||||
Replicas []Replica
|
||||
// PlainText — плоский текст расшифровки.
|
||||
PlainText string
|
||||
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением.
|
||||
// Raw — ответ провайдера целиком, как он пришёл, на хранение отдельным файлом.
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
@@ -17,16 +17,6 @@ const (
|
||||
EventOutcomeResumed = "resumed"
|
||||
)
|
||||
|
||||
// AllEventOrigins — закрытый перечень источников для схемы хранилища.
|
||||
func AllEventOrigins() []string {
|
||||
return []string{EventOriginPipeline, EventOriginHuman}
|
||||
}
|
||||
|
||||
// AllEventOutcomes — закрытый перечень исходов для схемы хранилища.
|
||||
func AllEventOutcomes() []string {
|
||||
return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed}
|
||||
}
|
||||
|
||||
// RecordEvent — строка журнала событий одной записи.
|
||||
//
|
||||
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package entity
|
||||
|
||||
// Состояния прежней модели. **Частью модели они не являются** и в перечень
|
||||
// рубежей не входят: цепочку рубежей объявляет `stage.go`, а закрытый перечень
|
||||
// для схемы — `AllStates()`.
|
||||
//
|
||||
// Живут они здесь по одной причине: шаг схемы `202608110001_init.go` заводил
|
||||
// прежнюю коллекцию задач этими значениями, а **применённый шаг схемы не
|
||||
// переписывается** — хранилище считает применённое по имени файла, и правка
|
||||
// сделала бы его другим шагом под прежним именем. Шаг ссылается на эти
|
||||
// константы, значит они обязаны существовать, пока существует он.
|
||||
//
|
||||
// Коллекцию, которую он заводил, удаляет шаг `202608140002`. Ни один живой путь
|
||||
// сервиса этих значений не читает и не пишет; `StateDone` в этом списке нет —
|
||||
// то же слово осталось именем конечного рубежа новой модели.
|
||||
const (
|
||||
StateCreated = "created"
|
||||
StateConverted = "converted"
|
||||
StateTranscribe = "transcribe"
|
||||
StateFailed = "failed"
|
||||
StateDead = "dead"
|
||||
)
|
||||
@@ -42,11 +42,6 @@ func IsKnownTextView(view string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
|
||||
func AllTextKinds() []string {
|
||||
return []string{TextKindTranscript, TextKindLiterary}
|
||||
}
|
||||
|
||||
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
|
||||
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
||||
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
|
||||
Reference in New Issue
Block a user