Compare commits
83
Commits
aa20b229f9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1dab24de1
|
||
|
|
b01bdabc37
|
||
|
|
1385dd3f5d
|
||
|
|
404be2bdd5
|
||
|
|
31ce520c5b
|
||
|
|
11269c1567
|
||
|
|
0aa9b3a567
|
||
|
|
52fe31319a
|
||
|
|
75c6f0168a
|
||
|
|
c9b7765646
|
||
|
|
1edf8cb225
|
||
|
|
ad5b5e377f
|
||
|
|
a8fb4793be
|
||
|
|
7f33c957e5
|
||
|
|
e4441f3c49
|
||
|
|
2bb252e018
|
||
|
|
97c6c7c440
|
||
|
|
daa4c3b6e4
|
||
|
|
91138dd39a
|
||
|
|
63a13404df
|
||
|
|
8c18abc24e
|
||
|
|
663021f712
|
||
|
|
c6ffda9aac
|
||
|
|
a5bc322814
|
||
|
|
3a2da3004b
|
||
|
|
79ff12548f
|
||
|
|
d88e56efcb
|
||
|
|
b4b19db6e4
|
||
|
|
8f7c3a057a
|
||
|
|
97ceb7bb69
|
||
|
|
9a964f2efc
|
||
|
|
1576d06735
|
||
|
|
d079f03350
|
||
|
|
a67cdee382
|
||
|
|
8af8ec2e54
|
||
|
|
b7d4660aef
|
||
|
|
62b2829cba
|
||
|
|
e660617ba0
|
||
|
|
ce0ae76977
|
||
|
|
312caf0fa3
|
||
|
|
cd57b68215
|
||
|
|
903941f587
|
||
|
|
4c87220d90
|
||
|
|
edcf8ede70
|
||
|
|
b733a84d6a
|
||
|
|
863ba3b42e
|
||
|
|
220a4374b1
|
||
|
|
ec136b50fb
|
||
|
|
54268b5933
|
||
|
|
539ed926cb
|
||
|
|
cb65967389
|
||
|
|
26256cdb06
|
||
|
|
6994feec55
|
||
|
|
3ffb5109a7
|
||
|
|
f7a8a1df9d
|
||
|
|
2c12376262
|
||
|
|
5501384cdc
|
||
|
|
00148bcfb5
|
||
|
|
32949e7b01
|
||
|
|
b76f2d7c7e
|
||
|
|
eacaf76d5f
|
||
|
|
f4d8c7ed50
|
||
|
|
bb9a67929c
|
||
|
|
bc5c35790e
|
||
|
|
f494dcb83e
|
||
|
|
d8d6bcc193
|
||
|
|
8bcd2c0059
|
||
|
|
37ccda3677
|
||
|
|
e1dfe662ea
|
||
|
|
0353517ec4
|
||
|
|
1d243ad2f6
|
||
|
|
6c7f006e75
|
||
|
|
8bffd30955
|
||
|
|
3925c637f3
|
||
|
|
35bde75b1f
|
||
|
|
870b6bc829
|
||
|
|
4a052ec99b
|
||
|
|
b46be019fc
|
||
|
|
8739b18a9f
|
||
|
|
ddc34b3182
|
||
|
|
c44f0e7582
|
||
|
|
d676df8a27
|
||
|
|
09228f23d8
|
@@ -0,0 +1,13 @@
|
|||||||
|
# Раскладка av-dev в этом проекте: версия и настройки проверок.
|
||||||
|
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
|
||||||
|
|
||||||
|
version = 5 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
|
||||||
|
|
||||||
|
[docs]
|
||||||
|
# каталог миграций: по нему docs.py сверяет схему с database.md
|
||||||
|
migrations = "internal/adapter/repo/sqlite/migrations"
|
||||||
|
|
||||||
|
[tasks]
|
||||||
|
# каталог задач от корня репозитория; имена частей — умолчания скрипта
|
||||||
|
dir = "tasks"
|
||||||
|
stage = "build"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"av-dev@av-dev-skills": true,
|
||||||
|
"av-dev-git@av-dev-skills": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Что не уезжает в контекст сборки образа.
|
||||||
|
#
|
||||||
|
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
|
||||||
|
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
|
||||||
|
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
|
||||||
|
# Сборка при этом зелёная — расхождение молчаливое.
|
||||||
|
#
|
||||||
|
# `.gitignore` этого не закрывает: docker его не читает.
|
||||||
|
|
||||||
|
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
|
||||||
|
web/node_modules/
|
||||||
|
web/embed/dist/
|
||||||
|
web/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
|
||||||
|
# у себя. В образе им делать нечего.
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
|
||||||
|
config.toml
|
||||||
|
.env
|
||||||
|
|
||||||
|
# История репозитория: в слой сборки не нужна.
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Каталоги процесса, а не сборки.
|
||||||
|
openspec/
|
||||||
|
tasks/
|
||||||
|
docs/
|
||||||
+15
-8
@@ -20,14 +20,9 @@ transcriber
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
# Database files
|
# Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
|
||||||
data/transcriber.db
|
# подкаталоги с файлами записей. Раскладку задаёт сервис.
|
||||||
data/transcriber.db-shm
|
data/
|
||||||
data/transcriber.db-wal
|
|
||||||
|
|
||||||
# Uploaded files
|
|
||||||
data/files/*
|
|
||||||
!data/files/.gitkeep
|
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -51,7 +46,19 @@ Thumbs.db
|
|||||||
# Config files
|
# Config files
|
||||||
config.toml
|
config.toml
|
||||||
|
|
||||||
|
# Переменные окружения: сервис их не читает, настройки приезжают из TOML.
|
||||||
|
# Строка стоит против того, чтобы секрет завёлся здесь руками: из этого файла
|
||||||
|
# он попадает в git тем же способом, каким попал бы из конфига.
|
||||||
|
.env
|
||||||
|
|
||||||
# Sample and test audio files
|
# Sample and test audio files
|
||||||
*.m4a
|
*.m4a
|
||||||
*.mp3
|
*.mp3
|
||||||
*.ogg
|
*.ogg
|
||||||
|
|
||||||
|
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
|
||||||
|
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
|
||||||
|
web/node_modules/
|
||||||
|
web/embed/dist/
|
||||||
|
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
|
||||||
|
web/*.tsbuildinfo
|
||||||
|
|||||||
+190
-2
@@ -1,15 +1,152 @@
|
|||||||
|
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
|
||||||
|
# «Механизировано»; здесь только настройка и «почему именно так».
|
||||||
|
#
|
||||||
|
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
||||||
|
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
|
||||||
|
# что проверяет правило, прозой в конвенциях не остаётся.
|
||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: standard
|
default: standard
|
||||||
enable:
|
enable:
|
||||||
|
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
|
||||||
- errorlint
|
- errorlint
|
||||||
|
# docs/conventions/errors.md: ошибки — только stdlib.
|
||||||
|
- depguard
|
||||||
|
# docs/conventions/logging.md: форма вызова slog.
|
||||||
|
- sloglint
|
||||||
|
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
|
||||||
|
- misspell
|
||||||
|
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
|
||||||
|
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
|
||||||
|
- forbidigo
|
||||||
|
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
|
||||||
|
# контекстом. Инвариант «принятая запись не теряется молча» держится
|
||||||
|
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
|
||||||
|
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
|
||||||
|
- noctx
|
||||||
|
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
|
||||||
|
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
|
||||||
|
# внешним сервисом — там, где отмена и нужна.
|
||||||
|
- contextcheck
|
||||||
|
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
|
||||||
|
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
|
||||||
|
# `Close` этим списком не отличается.
|
||||||
|
- bodyclose
|
||||||
|
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
|
||||||
|
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
|
||||||
|
- nilerr
|
||||||
|
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
|
||||||
|
# чтение в пустой результат.
|
||||||
|
- rowserrcheck
|
||||||
|
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
|
||||||
|
- sqlclosecheck
|
||||||
|
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
|
||||||
|
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
|
||||||
|
- testifylint
|
||||||
|
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
|
||||||
|
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
|
||||||
|
# подавлений в этом файле, но применённый к комментариям в коде.
|
||||||
|
- nolintlint
|
||||||
settings:
|
settings:
|
||||||
|
forbidigo:
|
||||||
|
# `analyze-types` включает суждение по типу приёмника, а не по печатному
|
||||||
|
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
|
||||||
|
# правилам не мешает: имена пакетов в шаблонах те же.
|
||||||
|
analyze-types: true
|
||||||
|
forbid:
|
||||||
|
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
|
||||||
|
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
|
||||||
|
# названы тем же правилом: запрет на одно имя обходится соседним.
|
||||||
|
- pattern: '^fmt\.Print.*$'
|
||||||
|
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||||
|
- pattern: '^print(ln)?$'
|
||||||
|
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||||
|
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
|
||||||
|
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
|
||||||
|
# одной правкой. Наш рабочий код окружение не читает вовсе — это
|
||||||
|
# правило и держит.
|
||||||
|
#
|
||||||
|
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
|
||||||
|
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
|
||||||
|
# Этот остаток назван прозой в docs/conventions/logging.md.
|
||||||
|
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
|
||||||
|
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
|
||||||
|
# Единая точка чтения времени — internal/clock: метка времени в UTC
|
||||||
|
# (`clock.Now`), измерение длительности с монотонными часами
|
||||||
|
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
|
||||||
|
# строками времена из разных зон.
|
||||||
|
- pattern: '^time\.Now$'
|
||||||
|
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
|
||||||
|
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
|
||||||
|
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
|
||||||
|
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
|
||||||
|
# снимок, который получит клиент, лежит отдельно и читается через
|
||||||
|
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
|
||||||
|
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
|
||||||
|
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
|
||||||
|
#
|
||||||
|
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
|
||||||
|
# цепочку `w.Header().Get` обходится одной лишней строкой —
|
||||||
|
# `h := w.Header()`, — а также чтением по индексу карты и обходом
|
||||||
|
# `range`. По типу под правило попадают все эти формы разом. Текстом его
|
||||||
|
# записать нельзя ещё и потому, что `.Header` носят и запрос
|
||||||
|
# (`req.Header.Set` в проверках законен), и снимок ответа
|
||||||
|
# (`w.Result().Header` — как раз то, к чему правило ведёт).
|
||||||
|
#
|
||||||
|
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
|
||||||
|
# заголовок через `w.Header().Set`, но у него приёмник —
|
||||||
|
# `http.ResponseWriter`, и под правило он не попадает.
|
||||||
|
- pattern: '^httptest\.ResponseRecorder\.Header$'
|
||||||
|
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||||
|
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
|
||||||
|
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
|
||||||
|
# почему по нему не судят ответ, — только это сообщение.
|
||||||
|
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
|
||||||
|
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||||
|
|
||||||
|
sloglint:
|
||||||
|
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
|
||||||
|
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
|
||||||
|
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
|
||||||
|
# один стиль на весь код, — и записано строкой в
|
||||||
|
# docs/conventions/logging.md, «Сообщение».
|
||||||
|
no-mixed-args: true
|
||||||
|
kv-only: true
|
||||||
|
# `msg` — константа: сообщение с подставленным значением не сгруппировать
|
||||||
|
# отбором, а данные для этого и кладут в поля.
|
||||||
|
static-msg: true
|
||||||
|
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
|
||||||
|
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
|
||||||
|
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
|
||||||
|
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
main:
|
||||||
|
deny:
|
||||||
|
- pkg: github.com/pkg/errors
|
||||||
|
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
|
||||||
|
- pkg: github.com/cockroachdb/errors
|
||||||
|
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
|
||||||
|
|
||||||
|
nolintlint:
|
||||||
|
# Подавление без причины снимают при первом же неудобстве: снимающий не
|
||||||
|
# знает, что оно ловило. Те же два требования, что у подавлений в этом
|
||||||
|
# файле, — имя линтера и причина строкой.
|
||||||
|
require-explanation: true
|
||||||
|
require-specific: true
|
||||||
|
# Подавление, которому нечего подавлять, — след починенного места, и
|
||||||
|
# краснеть оно обязано: иначе перечень подавлений врёт.
|
||||||
|
allow-unused: false
|
||||||
|
|
||||||
errcheck:
|
errcheck:
|
||||||
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
|
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
|
||||||
# теряется молча» принимается реализацией, которая его теряет. Отказ,
|
# теряется молча» принимается реализацией, которая его теряет. Отказ,
|
||||||
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
|
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
|
||||||
check-blank: true
|
check-blank: true
|
||||||
|
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
|
||||||
|
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
|
||||||
|
check-type-assertions: true
|
||||||
exclude-functions:
|
exclude-functions:
|
||||||
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
|
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
|
||||||
- (io.Closer).Close
|
- (io.Closer).Close
|
||||||
@@ -17,8 +154,59 @@ linters:
|
|||||||
- (*os.File).Close
|
- (*os.File).Close
|
||||||
- (io.ReadCloser).Close
|
- (io.ReadCloser).Close
|
||||||
- os.Remove
|
- os.Remove
|
||||||
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
# Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
|
||||||
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
# а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
|
||||||
|
# закрытия нет.
|
||||||
|
- (*database/sql.Rows).Close
|
||||||
|
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
|
||||||
|
# отвечает на него «уже закончена», и проверка этого отказа означала бы
|
||||||
|
# разбор штатного исхода.
|
||||||
|
- (*database/sql.Tx).Rollback
|
||||||
|
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
|
||||||
|
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
|
||||||
|
# браузере наполняла бы журнал ничем.
|
||||||
|
- (*encoding/json.Encoder).Encode
|
||||||
|
- (net/http.ResponseWriter).Write
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
|
||||||
|
# `Header()` и есть способ отдать заголовок.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path-except: '_test\.go$'
|
||||||
|
text: 'живой карте заголовков'
|
||||||
|
# Единая точка чтения времени сама читает время — иначе ей нечем.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: 'internal/clock/'
|
||||||
|
text: 'time.Now'
|
||||||
|
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
|
||||||
|
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
|
||||||
|
# процессу. Настройками приложения это не является. Исключение объявлено по
|
||||||
|
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
|
||||||
|
# исключение обязано покрывать те же четыре.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'конфигурация только из TOML'
|
||||||
|
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
|
||||||
|
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
|
||||||
|
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'time.Now'
|
||||||
|
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
|
||||||
|
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
|
||||||
|
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
|
||||||
|
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
|
||||||
|
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
|
||||||
|
# подсуден.
|
||||||
|
- linters:
|
||||||
|
- noctx
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'httptest\.NewRequest'
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
|
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
|
||||||
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
|
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
|
||||||
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
|
SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
|
||||||
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
|
Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
|
||||||
панель администратора.
|
рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
|
||||||
|
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
|
||||||
|
задачи, которая свяжет чат с учётной записью.
|
||||||
|
|
||||||
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
||||||
руками не правит и в форматы документов не экспортирует, учётных записей не
|
руками не правит и в форматы документов не экспортирует, учётных записей не
|
||||||
@@ -24,19 +26,45 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
|||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
|
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
|
||||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
|
||||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
маршруты и слои на `net/http`, файлы записей своим каталогом,
|
||||||
|
`aws-sdk-go-v2` для Object
|
||||||
|
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
|
||||||
|
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
|
||||||
|
его Biome и юнит-тесты Vue. Сборка —
|
||||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||||
|
|
||||||
|
## Проектирование
|
||||||
|
|
||||||
|
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
|
||||||
|
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
|
||||||
|
реализацию подставляет точка входа. Направления держат тесты-сканеры
|
||||||
|
`internal/archrules`, а не договорённость.
|
||||||
|
|
||||||
|
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
|
||||||
|
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
|
||||||
|
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
|
||||||
|
прикладной слой назначает порядок шагов, а не правила.
|
||||||
|
|
||||||
|
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
|
||||||
|
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
|
||||||
|
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
|
||||||
|
ним молча.
|
||||||
|
|
||||||
## Инварианты
|
## Инварианты
|
||||||
|
|
||||||
Что нарушать нельзя.
|
Что нарушать нельзя.
|
||||||
|
|
||||||
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
|
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
|
||||||
Storage не попадают в git, в лог, в ответ пользователю и в колонку
|
Storage не попадают в git, в лог, в ответ пользователю и
|
||||||
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
|
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||||
всех местах выкладки. **critical**
|
вручную во всех местах выкладки. **critical**
|
||||||
|
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
|
||||||
|
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
|
||||||
|
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
|
||||||
|
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
|
||||||
|
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
|
||||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||||
пользователя и его сообщение в лог не пишутся — только длина и
|
пользователя и его сообщение в лог не пишутся — только длина и
|
||||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||||
@@ -48,82 +76,183 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
приведённым к перечню известных форматов. Границу держит спека `intake`,
|
приведённым к перечню известных форматов. Границу держит спека `intake`,
|
||||||
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
|
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
|
||||||
остаток — [docs/security.md](docs/security.md).
|
остаток — [docs/security.md](docs/security.md).
|
||||||
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
|
|
||||||
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
|
|
||||||
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
|
|
||||||
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
|
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
|
||||||
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
|
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
|
||||||
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
|
и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
|
||||||
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
|
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
|
||||||
**major**
|
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
|
||||||
|
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
|
||||||
|
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
|
||||||
|
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
|
||||||
|
переехала на карточку. **major**
|
||||||
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
|
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
|
||||||
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
||||||
запись раз в секунду на каждый воркер. **major**
|
запись раз в секунду на каждый воркер. **major**
|
||||||
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
|
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
|
||||||
файлом шага. Необратимо: хранилище считает применённое по имени файла.
|
файлом шага. Необратимо: учёт применённого ведёт сама база.
|
||||||
**critical**
|
**critical**
|
||||||
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
|
*Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
|
||||||
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
|
встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
|
||||||
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
|
||||||
|
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
|
||||||
|
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
|
||||||
|
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
|
||||||
|
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
|
||||||
|
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
|
||||||
|
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
|
||||||
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
||||||
записи. **critical**
|
записи. **critical**
|
||||||
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
|
- **Колонки записи правятся в трёх местах** пакета хранилища —
|
||||||
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
|
`writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
|
||||||
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
|
`rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
|
||||||
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
|
||||||
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
|
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
|
||||||
поле теряется **только у задачи, попавшей к воркеру**. **major**
|
ноль поверх сохранённого.
|
||||||
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
|
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
|
||||||
достался другому, завершается без записи и без ответа отправителю. Иначе два
|
возвращает идентификатор и признак своего захвата, и перечень перестал расти
|
||||||
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
с моделью. Отображение при этом идёт **по имени колонки**: именованные
|
||||||
|
параметры запроса и место назначения, найденное по имени, — позиционный список
|
||||||
|
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
|
||||||
|
`internal/archrules`. **major**
|
||||||
|
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
|
||||||
|
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
|
||||||
|
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
|
||||||
|
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
||||||
|
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
|
||||||
|
следа. Сверку держат правила `internal/archrules`. **major**
|
||||||
|
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
|
||||||
|
Признак захвата уникален для каждого захвата, и запись результата условна по
|
||||||
|
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
||||||
|
другому — по протуханию срока или после того, как человек вернул запись в
|
||||||
|
работу подкомандой оснастки, — завершается без записи результата. Условие
|
||||||
|
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
||||||
|
по очереди, портя её результат. **major**
|
||||||
|
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
|
||||||
|
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
|
||||||
|
держит это схема, а не договорённость: колонка объявлена внешним ключом на
|
||||||
|
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
|
||||||
|
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
|
||||||
|
распознавание и не доставалась потом никому. Правило со стороны
|
||||||
|
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
|
||||||
|
записью, потому что схема запрещает **заводить** ничью, а это правило —
|
||||||
|
**спрашивать** ничьим именем. **major**
|
||||||
|
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
|
||||||
|
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
|
||||||
|
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
|
||||||
|
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
|
||||||
|
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
|
||||||
**major**
|
**major**
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./... # CGO не нужен
|
go build ./... # CGO не нужен
|
||||||
go test ./...
|
go test ./... # в гейте идёт с -race, и там нужен компилятор C
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -l .
|
gofmt -l .
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
|
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||||
|
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
|
||||||
|
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||||
task gate # весь набор проверок разом
|
task gate # весь набор проверок разом
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
|
||||||
|
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
|
||||||
|
становится docker — тот же, которым собирается образ.
|
||||||
|
|
||||||
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
||||||
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
|
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
||||||
[docs/conventions/config.md](docs/conventions/config.md) строками
|
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
||||||
«*Расхождение:*».
|
«*Расхождение:*».
|
||||||
|
|
||||||
## Гейт
|
## Гейт
|
||||||
|
|
||||||
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
|
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
|
||||||
`origin/master`; переопределяется `task gate BASE=<rev>`.
|
`origin/master`; переопределяется `task gate BASE=<rev>`.
|
||||||
|
- **Какое правило чем проверяется** — конвенция
|
||||||
|
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
|
||||||
|
семантика гейта, там перечень правил, подавлений и место настройки каждого;
|
||||||
|
перечень здесь не повторяется.
|
||||||
- **Где логи шагов:** вывод команды, отдельного файла нет.
|
- **Где логи шагов:** вывод команды, отдельного файла нет.
|
||||||
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
|
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
|
||||||
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий:
|
`docs.py check`, `tasks.py check`, `openspec.py check` и
|
||||||
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта,
|
`scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
|
||||||
каталог не найден), 4 внутренний сбой.
|
2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
|
||||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
|
||||||
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов,
|
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
|
||||||
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё
|
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
|
||||||
|
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
|
||||||
|
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
|
||||||
|
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
|
||||||
|
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
|
||||||
|
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
|
||||||
|
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
|
||||||
|
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
|
||||||
|
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||||
|
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||||
|
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||||
|
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
|
||||||
|
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
|
||||||
|
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||||
|
схемы,
|
||||||
|
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||||
|
версий Go, дрейф раскладки документов,
|
||||||
|
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
|
||||||
|
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
|
||||||
|
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
|
||||||
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
|
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
|
||||||
именем недостающего плагина, а не пропускается молча.
|
именем недостающего плагина, а не пропускается молча.
|
||||||
|
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
|
||||||
|
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
||||||
|
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
||||||
|
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||||
|
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
||||||
|
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||||
|
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
|
||||||
|
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
|
||||||
|
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
|
||||||
|
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
|
||||||
|
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
|
||||||
|
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
|
||||||
|
гейт, который висит, хуже красного.
|
||||||
|
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
|
||||||
|
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||||
|
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
||||||
|
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
||||||
|
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
|
||||||
|
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
|
||||||
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
|
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
|
||||||
|
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
|
||||||
|
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
|
||||||
|
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
||||||
|
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
||||||
|
это обязательно;
|
||||||
|
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
|
||||||
|
правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||||
|
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
||||||
|
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
||||||
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
||||||
коммита. Полную историю никто не проверяет;
|
коммита. Полную историю никто не проверяет;
|
||||||
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
||||||
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
|
их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
|
||||||
- покрытие изменённых строк не считается ничем.
|
- покрытие изменённых строк не считается ничем.
|
||||||
|
|
||||||
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
|
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
|
||||||
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
|
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
|
||||||
Списывать отказ на долг больше нельзя: списывать не на что.
|
Списывать отказ на долг больше нельзя: списывать не на что.
|
||||||
|
|
||||||
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
||||||
новый:
|
новый:
|
||||||
|
|
||||||
|
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
|
||||||
|
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
|
||||||
|
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
|
||||||
|
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
|
||||||
|
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
|
||||||
|
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
|
||||||
|
|
||||||
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
|
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
|
||||||
сравнения ошибок приведением типа. Закрыто задачей
|
сравнения ошибок приведением типа. Закрыто задачей
|
||||||
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
|
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
|
||||||
@@ -134,16 +263,39 @@ task gate # весь набор проверок разом
|
|||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
||||||
база (`data/data.db`), и записи живых людей
|
база (`data/transcriber.db`), и записи живых людей
|
||||||
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
(`data/records/<запись>/`). На стройке под ним пусто и сервис
|
||||||
ронять и пересоздавать можно свободно.
|
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
|
||||||
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
||||||
перехватывает обновления у работающего, и пользователь теряет ответы.
|
его ронять и пересоздавать можно свободно.
|
||||||
|
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
|
||||||
|
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
|
||||||
|
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
|
||||||
|
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||||
|
`internal/adapter/recognizer/memory.go`. Подробности строками в
|
||||||
|
`config.example.toml`.
|
||||||
|
**На машине без прокси представиться нечем**: сервис узнаёт
|
||||||
|
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
|
||||||
|
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
|
||||||
|
процессом: рецепт из трёх правок записан связным блоком в
|
||||||
|
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
|
||||||
|
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
|
||||||
|
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
|
||||||
|
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
|
||||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||||
подставляй `internal/adapter/recognizer/memory.go`.
|
подставляй `internal/adapter/recognizer/memory.go`.
|
||||||
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
|
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
|
||||||
запускает человек.
|
запускает человек.
|
||||||
|
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
|
||||||
|
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
|
||||||
|
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
|
||||||
|
механизация покрытия изменённого кода, мутационная сверка оракулов и
|
||||||
|
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
|
||||||
|
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
|
||||||
|
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
|
||||||
|
сценариев шага сверки версий Go, единственный такой файл в проекте.
|
||||||
|
Исключений у запрета нет.
|
||||||
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
||||||
временном каталоге и убирают за собой.
|
временном каталоге и убирают за собой.
|
||||||
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
||||||
@@ -151,6 +303,14 @@ task gate # весь набор проверок разом
|
|||||||
|
|
||||||
## Работа
|
## Работа
|
||||||
|
|
||||||
|
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
|
||||||
|
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
|
||||||
|
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
|
||||||
|
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
|
||||||
|
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
|
||||||
|
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
|
||||||
|
решение человека; боевой каталог данных остаётся под запретом; выкладку
|
||||||
|
по-прежнему запускает человек.
|
||||||
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
|
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
|
||||||
- **Сообщение коммита** без трейлера `Co-Authored-By`.
|
- **Сообщение коммита** без трейлера `Co-Authored-By`.
|
||||||
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
|
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
|
||||||
@@ -158,9 +318,9 @@ task gate # весь набор проверок разом
|
|||||||
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
||||||
секрета.
|
секрета.
|
||||||
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
||||||
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
|
твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
|
||||||
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
|
правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
|
||||||
задачами.
|
красный шаг больше не на что.
|
||||||
- **Ориентир по размеру порции:** не замерялся.
|
- **Ориентир по размеру порции:** не замерялся.
|
||||||
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
|
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
|
||||||
поимённо.
|
поимённо.
|
||||||
@@ -169,4 +329,19 @@ task gate # весь набор проверок разом
|
|||||||
|
|
||||||
- Документация, комментарии, сообщения коммитов — русский.
|
- Документация, комментарии, сообщения коммитов — русский.
|
||||||
- Код и идентификаторы — английский.
|
- Код и идентификаторы — английский.
|
||||||
- Текст, который видит пользователь Telegram, — русский.
|
- Текст, который видит пользователь сервиса, — русский.
|
||||||
|
- **Точного числа накопленного в документах нет.** «Три capability», «пять
|
||||||
|
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
|
||||||
|
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
|
||||||
|
такое не считает, а читатель верит написанному. Ссылаться можно только на
|
||||||
|
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
|
||||||
|
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
|
||||||
|
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
|
||||||
|
и потому протухает в одиночку.
|
||||||
|
|
||||||
|
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
|
||||||
|
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
|
||||||
|
экране. Так же законно **историческое** число в записи о прошлом: «решением от
|
||||||
|
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
|
||||||
|
Настройки с числовым значением — свой случай, их дом
|
||||||
|
[docs/database.md](docs/database.md).
|
||||||
|
|||||||
+39
-3
@@ -1,5 +1,30 @@
|
|||||||
|
# Front build stage
|
||||||
|
#
|
||||||
|
# Приложение собирается до бинарника: вшивание требует готового каталога.
|
||||||
|
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
|
||||||
|
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
|
||||||
|
#
|
||||||
|
# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы
|
||||||
|
# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA`
|
||||||
|
# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ
|
||||||
|
# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет,
|
||||||
|
# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает.
|
||||||
|
# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся
|
||||||
|
# ценой одной сборки, а не размером выкладки.
|
||||||
|
FROM docker.io/library/node:24 AS front-build
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||||
|
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM docker.io/library/golang:1.25-alpine AS build-env
|
FROM docker.io/library/golang:1.26-alpine AS build-env
|
||||||
|
|
||||||
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
||||||
# и CGO больше не требуется.
|
# и CGO больше не требуется.
|
||||||
@@ -16,8 +41,14 @@ RUN go mod download
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
|
||||||
|
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
|
||||||
|
COPY --from=front-build /web/embed/dist ./web/embed/dist
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 go build -o transcriber .
|
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
|
||||||
|
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
|
||||||
|
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
|
||||||
|
|
||||||
# ----------------
|
# ----------------
|
||||||
# Production stage
|
# Production stage
|
||||||
@@ -60,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
|
|||||||
RUN chmod 755 /usr/bin/entrypoint
|
RUN chmod 755 /usr/bin/entrypoint
|
||||||
|
|
||||||
# Set user
|
# Set user
|
||||||
USER transcriber
|
#
|
||||||
|
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
|
||||||
|
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
|
||||||
|
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
|
||||||
|
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
# Transcriber Service
|
# Transcriber Service
|
||||||
|
|
||||||
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API.
|
Сервис расшифровки аудиозаписей. Вход один — HTTP API.
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
|
|
||||||
- Приём аудиофайлов через HTTP API
|
- Приём аудиофайлов через HTTP API
|
||||||
- Конвертация в ogg через ffmpeg
|
- Конвертация в ogg через ffmpeg
|
||||||
- Распознавание речи через Yandex SpeechKit
|
- Распознавание речи через Yandex SpeechKit
|
||||||
- Отслеживание статуса задач расшифровки
|
- Отслеживание статуса задач расшифровки
|
||||||
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
|
- Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- **Веб-фреймворк**: gin-gonic/gin
|
- **Язык**: Go 1.26, CGO не нужен
|
||||||
- **Telegram**: go-telegram-bot-api
|
- **HTTP**: стандартная библиотека, `net/http`
|
||||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||||
- **Конвертация**: ffmpeg
|
- **Конвертация**: ffmpeg
|
||||||
- **Хранилище, файлы и панель**: встроенная PocketBase
|
- **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
|
||||||
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
|
- **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
|
||||||
|
- **Файлы записей**: свой каталог, подкаталог на запись
|
||||||
- **Метрики**: prometheus/client_golang
|
- **Метрики**: prometheus/client_golang
|
||||||
|
|
||||||
## Установка и запуск
|
## Установка и запуск
|
||||||
@@ -30,22 +30,34 @@
|
|||||||
```
|
```
|
||||||
3. Скопируйте образец конфига и заполните его:
|
3. Скопируйте образец конфига и заполните его:
|
||||||
```bash
|
```bash
|
||||||
cp config.dist.toml config.toml
|
cp config.example.toml config.toml
|
||||||
```
|
```
|
||||||
4. Запустите приложение:
|
4. Запустите приложение:
|
||||||
```bash
|
```bash
|
||||||
go run . -c config.toml
|
go run ./cmd/transcriber -c config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
|
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
|
||||||
установленный `ffmpeg`.
|
установленный `ffmpeg`.
|
||||||
|
|
||||||
### Белый список Telegram
|
### Кого пускают
|
||||||
|
|
||||||
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он
|
Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
|
||||||
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»;
|
прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
|
||||||
известные прорехи образца конфига, включая недостающий ключ белого списка, —
|
перечень доверенных адресов в секции `[auth]`. Подробности —
|
||||||
[docs/conventions/config.md](docs/conventions/config.md).
|
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
|
||||||
|
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
|
||||||
|
|
||||||
|
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
|
||||||
|
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
|
||||||
|
открывают по адресу сервиса.
|
||||||
|
|
||||||
|
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
|
||||||
|
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
|
||||||
|
|
||||||
|
Заполненная секция имитации при выключенном предохранителе роняет
|
||||||
|
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
|
||||||
|
сам, никого не спросив, и в бою этот ключ стоит `false`.
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
@@ -61,9 +73,16 @@ inv pl -- transcriber
|
|||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` —
|
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
|
||||||
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом
|
записи, `GET /app/audiorecords` — страница своих записей,
|
||||||
`transcriber_`, `GET /health` — проверка живости.
|
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
|
||||||
|
текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
|
||||||
|
копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
|
||||||
|
объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
|
||||||
|
Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
|
||||||
|
Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
|
||||||
|
([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
|
||||||
|
ничего: всякий прочий путь получает разметку приложения.
|
||||||
|
|
||||||
Контракт приёма и опроса нормативен и живёт в
|
Контракт приёма и опроса нормативен и живёт в
|
||||||
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
|
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
|
||||||
@@ -80,41 +99,48 @@ inv pl -- transcriber
|
|||||||
|
|
||||||
```
|
```
|
||||||
transcriber/
|
transcriber/
|
||||||
├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск
|
├── cmd/
|
||||||
|
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
||||||
|
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── entity/ # Модели: задача, файл, результат распознавания
|
│ ├── entity/ # Модели: запись, файл, результат распознавания
|
||||||
|
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
|
||||||
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
||||||
│ ├── config/ # Разбор config.toml
|
│ ├── config/ # Разбор config.toml
|
||||||
│ ├── metrics/ # Метрики Prometheus
|
│ ├── metrics/ # Метрики Prometheus
|
||||||
│ ├── service/ # Конвейер расшифровки
|
│ ├── service/ # Конвейер расшифровки
|
||||||
│ ├── controller/
|
│ ├── controller/
|
||||||
│ │ ├── http/ # HTTP-обработчики
|
│ │ ├── http/ # HTTP-обработчики
|
||||||
│ │ ├── tg/ # Telegram-бот
|
|
||||||
│ │ └── worker/ # Фоновые воркеры
|
│ │ └── worker/ # Фоновые воркеры
|
||||||
│ └── adapter/
|
│ └── adapter/
|
||||||
│ ├── converter/ffmpeg/ # Конвертация аудио
|
│ ├── converter/ffmpeg/ # Конвертация аудио
|
||||||
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
||||||
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
||||||
│ ├── telegram/ # Отправка сообщений
|
│ └── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
|
||||||
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
|
|
||||||
└── data/ # Каталог данных: база и файлы записей вместе
|
└── data/ # Каталог данных: база и файлы записей вместе
|
||||||
├── data.db # База хранилища (создаётся автоматически)
|
├── transcriber.db # База (создаётся автоматически)
|
||||||
└── storage/ # Файлы записей в раскладке хранилища
|
├── migrate.lock # Замок наката схемы
|
||||||
|
└── records/ # Файлы записей: подкаталог на запись
|
||||||
```
|
```
|
||||||
|
|
||||||
## Хранилище
|
## Хранилище
|
||||||
|
|
||||||
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
|
Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и
|
||||||
идентификаторов, а также механика захвата задачи воркером —
|
идентификаторов, раскладка файлов записи и механика захвата задачи воркером —
|
||||||
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
|
[docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное
|
||||||
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
|
его действие вне экранов — возврат остановленной записи в работу подкомандой
|
||||||
в журнал при первом запуске.
|
оснастки.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/devtools resume -c config.toml <идентификатор записи>
|
||||||
|
```
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Схему двигают шаги миграций PocketBase на Go —
|
Схему двигают шаги `pressly/goose/v3` —
|
||||||
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
|
`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
|
||||||
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
|
начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
|
||||||
|
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
|
||||||
переписывается: изменение — только новым файлом шага.
|
переписывается: изменение — только новым файлом шага.
|
||||||
|
|
||||||
Проверки перед коммитом — одной командой:
|
Проверки перед коммитом — одной командой:
|
||||||
|
|||||||
+240
-10
@@ -9,15 +9,24 @@ vars:
|
|||||||
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
|
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
|
||||||
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
|
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
|
||||||
# дрейф своего каталога, и выпадение одного не подменяется другим.
|
# дрейф своего каталога, и выпадение одного не подменяется другим.
|
||||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
|
#
|
||||||
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
|
# Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
|
||||||
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
|
# общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
|
||||||
|
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
||||||
|
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
||||||
|
# по коду скрипта, а не по коду `task`.
|
||||||
|
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/canon/scripts/docs.py"}}'
|
||||||
|
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/task-track/scripts/tasks.py"}}'
|
||||||
|
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|
||||||
gate:
|
gate:
|
||||||
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
|
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
|
||||||
cmds:
|
cmds:
|
||||||
|
# Приложение собирается первым: вшивание требует готового каталога, и
|
||||||
|
# `go build` без него соберёт бинарник со вчерашней сборкой.
|
||||||
|
- task: front
|
||||||
- go build ./...
|
- go build ./...
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
- |
|
- |
|
||||||
@@ -27,11 +36,213 @@ tasks:
|
|||||||
echo "gofmt: файлы выше не отформатированы"
|
echo "gofmt: файлы выше не отформатированы"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- go test ./...
|
- task: tests
|
||||||
- golangci-lint run
|
- golangci-lint run
|
||||||
|
- task: shell
|
||||||
|
- task: dockerfile
|
||||||
|
- task: go-version
|
||||||
|
- task: migrations
|
||||||
- task: docs
|
- task: docs
|
||||||
- task: tasks
|
- task: tasks
|
||||||
- task: openspec
|
- task: openspec
|
||||||
|
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
|
||||||
|
- task: vulns
|
||||||
|
|
||||||
|
tests:
|
||||||
|
desc: 'Тесты с детектором гонок'
|
||||||
|
cmds:
|
||||||
|
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
|
||||||
|
# очередь, и «результат пишет только держатель захвата» — утверждение о
|
||||||
|
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
|
||||||
|
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
|
||||||
|
# отсутствие — отказ окружения, код 3, а не отказ проверки.
|
||||||
|
# Окружение проверяется **после** обычного прогона, а не вместо него:
|
||||||
|
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
|
||||||
|
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
|
||||||
|
# совет «включи CGO_ENABLED=1» бесполезен.
|
||||||
|
- |
|
||||||
|
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
|
||||||
|
go test ./... || exit 1
|
||||||
|
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
|
||||||
|
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [ "$(go env CGO_ENABLED)" != "1" ]; then
|
||||||
|
go test ./... || exit 1
|
||||||
|
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
|
||||||
|
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
desc: 'Применённый шаг схемы не переписывается'
|
||||||
|
cmds:
|
||||||
|
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
|
||||||
|
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
|
||||||
|
# Компилятор этого не держит, и до этого шага не держало ничто.
|
||||||
|
#
|
||||||
|
# Судится каталог шагов против базы диффа: у файла шага допустим один
|
||||||
|
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
|
||||||
|
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
|
||||||
|
# прибавляется именно там, и запрет на него запретил бы заведение шага.
|
||||||
|
- |
|
||||||
|
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
|
||||||
|
echo "база диффа не найдена: {{.BASE}}"
|
||||||
|
echo "задай свою: task migrations BASE=<rev>"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
|
||||||
|
# `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
|
||||||
|
# бы факту второй дом: каталог переехал бы, а один из двух стражей молча
|
||||||
|
# позеленел. До слияния плагинов файл звался docs/.docs.json.
|
||||||
|
dir=$(python3 -c 'import tomllib; print(tomllib.load(open(".av-dev.toml","rb"))["docs"]["migrations"])' 2>/dev/null) || dir=""
|
||||||
|
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||||
|
echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
||||||
|
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
||||||
|
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
||||||
|
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
||||||
|
echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
||||||
|
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
|
||||||
|
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
|
||||||
|
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
|
||||||
|
for base in {{.BASE}} HEAD; do
|
||||||
|
touched=$(git diff --name-status "$base" -- "$dir" \
|
||||||
|
| grep -E '[0-9]{12}_[^/]*\.go$' \
|
||||||
|
| grep -vE '^A[[:space:]]' || true)
|
||||||
|
if [ -n "$touched" ]; then
|
||||||
|
echo "база $base:"
|
||||||
|
echo "$touched"
|
||||||
|
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
|
||||||
|
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
front:
|
||||||
|
desc: 'Приложение: зависимости, проверки, сборка'
|
||||||
|
vars:
|
||||||
|
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
|
||||||
|
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
|
||||||
|
# сверки у него, в отличие от версий Go, нет.
|
||||||
|
NODE_IMAGE:
|
||||||
|
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
|
||||||
|
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
|
||||||
|
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
|
||||||
|
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
|
||||||
|
cmds:
|
||||||
|
# Node на машину не ставится — он зовётся контейнером, тем же образом,
|
||||||
|
# каким собирается ступень образа. Требованием к машине остаётся docker.
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker не найден в PATH"
|
||||||
|
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "{{.NODE_IMAGE}}" ]; then
|
||||||
|
echo "в Dockerfile не нашлось ступени с образом node"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "{{.NPM_CACHE}}"
|
||||||
|
|
||||||
|
run() {
|
||||||
|
docker run --rm \
|
||||||
|
-u "$(id -u):$(id -g)" \
|
||||||
|
-e npm_config_cache=/npmcache \
|
||||||
|
-v "{{.NPM_CACHE}}:/npmcache" \
|
||||||
|
-v "$PWD/web:/web" \
|
||||||
|
-w /web \
|
||||||
|
"{{.NODE_IMAGE}}" \
|
||||||
|
sh -c "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||||
|
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
|
||||||
|
# расходилось бы с собранным в образе.
|
||||||
|
#
|
||||||
|
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
|
||||||
|
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
|
||||||
|
# не требует вовсе.
|
||||||
|
if ! run 'npm ci --offline' >/dev/null 2>&1; then
|
||||||
|
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
|
||||||
|
# коротким обращением. Без этой проверки установщик уходит в повторы с
|
||||||
|
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
|
||||||
|
# хуже красного — он не даёт ни исхода, ни причины.
|
||||||
|
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
|
||||||
|
echo "реестр пакетов недоступен"
|
||||||
|
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
|
||||||
|
# отказ установки это отказ проекта: замок разошёлся с package.json,
|
||||||
|
# пакет снят из реестра, сломался его postinstall. По словарю кодов
|
||||||
|
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
|
||||||
|
# и сеть вместо `git diff web/package-lock.json`.
|
||||||
|
if ! run 'npm ci'; then
|
||||||
|
echo "зависимости приложения не установились, а реестр доступен"
|
||||||
|
echo "смотри расхождение web/package-lock.json с web/package.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
run 'npm run check && npm run test && npm run build'
|
||||||
|
|
||||||
|
shell:
|
||||||
|
desc: 'shellcheck на скрипты оболочки'
|
||||||
|
cmds:
|
||||||
|
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
|
||||||
|
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
|
||||||
|
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
|
||||||
|
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
|
||||||
|
# `go vet`, ни `golangci-lint`.
|
||||||
|
- |
|
||||||
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
echo "shellcheck не найден в PATH"
|
||||||
|
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
|
||||||
|
|
||||||
|
dockerfile:
|
||||||
|
desc: 'hadolint на Dockerfile'
|
||||||
|
cmds:
|
||||||
|
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
|
||||||
|
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
|
||||||
|
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
|
||||||
|
# держит старые версии в репозитории, и закрепление ломает сборку через
|
||||||
|
# недели — то есть лечение хуже болезни.
|
||||||
|
- |
|
||||||
|
if ! command -v hadolint >/dev/null 2>&1; then
|
||||||
|
echo "hadolint не найден в PATH"
|
||||||
|
echo "поставь: https://github.com/hadolint/hadolint/releases"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
|
||||||
|
|
||||||
|
go-version:
|
||||||
|
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
|
||||||
|
cmds:
|
||||||
|
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
|
||||||
|
# сломанное дерево, а не непоставленный плагин, и переопределять путь
|
||||||
|
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
|
||||||
|
- |
|
||||||
|
py=scripts/check-go-version.sh
|
||||||
|
if [ ! -f "$py" ]; then
|
||||||
|
echo "$py не найден: дерево репозитория неполно"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
sh "$py"
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
desc: 'Раскладка docs/ против канона'
|
desc: 'Раскладка docs/ против канона'
|
||||||
@@ -41,8 +252,8 @@ tasks:
|
|||||||
py=$(eval echo {{.DOCS_PY}})
|
py=$(eval echo {{.DOCS_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "docs.py не найден: $py"
|
echo "docs.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --base {{.BASE}}
|
python3 "$py" check --base {{.BASE}}
|
||||||
|
|
||||||
@@ -53,8 +264,8 @@ tasks:
|
|||||||
py=$(eval echo {{.TASKS_PY}})
|
py=$(eval echo {{.TASKS_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "tasks.py не найден: $py"
|
echo "tasks.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir tasks
|
python3 "$py" check --dir tasks
|
||||||
|
|
||||||
@@ -65,11 +276,30 @@ tasks:
|
|||||||
py=$(eval echo {{.OPENSPEC_PY}})
|
py=$(eval echo {{.OPENSPEC_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "openspec.py не найден: $py"
|
echo "openspec.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir .
|
python3 "$py" check --dir .
|
||||||
|
|
||||||
|
vulns:
|
||||||
|
desc: 'Достижимые из кода уязвимости в зависимостях'
|
||||||
|
cmds:
|
||||||
|
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
|
||||||
|
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
|
||||||
|
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
|
||||||
|
#
|
||||||
|
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
|
||||||
|
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
|
||||||
|
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
|
||||||
|
# сети шаг краснеет, а не пропускается молча.
|
||||||
|
- |
|
||||||
|
if ! command -v govulncheck >/dev/null 2>&1; then
|
||||||
|
echo "govulncheck не найден в PATH"
|
||||||
|
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
|
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
|
||||||
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
|
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
|
||||||
image:
|
image:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Command devtools — оснастка разработчика: то, что нужно для локального
|
||||||
|
// прогона и никогда не едет в боевой образ.
|
||||||
|
//
|
||||||
|
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
|
||||||
|
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
|
||||||
|
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
|
||||||
|
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||||
|
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||||
|
//
|
||||||
|
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
|
||||||
|
// на место панели владельца: панели у сервиса больше нет, а экраны правки
|
||||||
|
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
|
||||||
|
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
|
||||||
|
// входа локального прогона подставляет сам сервис по своим настройкам.
|
||||||
|
//
|
||||||
|
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
|
||||||
|
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
|
||||||
|
//
|
||||||
|
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(0)
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "resume":
|
||||||
|
runResume(os.Args[2:])
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||||
|
|
||||||
|
Подкоманды:
|
||||||
|
resume вернуть остановленную запись в работу
|
||||||
|
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stepResume — чем возврат в работу подписывается в журнале событий записи.
|
||||||
|
const stepResume = "resume"
|
||||||
|
|
||||||
|
// runResume возвращает остановленную запись в работу.
|
||||||
|
//
|
||||||
|
// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а
|
||||||
|
// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал
|
||||||
|
// панелью, отложить до экранов нельзя было одно — возврат остановленной записи.
|
||||||
|
//
|
||||||
|
// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан
|
||||||
|
// сбросить — признак остановки, признак захвата и срок его протухания, число
|
||||||
|
// отказов, паузу и время входа в рубеж, — исполняет домен одним действием.
|
||||||
|
// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата,
|
||||||
|
// либо останавливаемой снова первым же захватом — молча, без единой строки.
|
||||||
|
//
|
||||||
|
// Событие журнала записи пишется с происхождением «человек»: иначе запись,
|
||||||
|
// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от
|
||||||
|
// записи, которую конвейер вёл без остановок, а происхождение события перестаёт
|
||||||
|
// различать что-либо.
|
||||||
|
func runResume(args []string) {
|
||||||
|
flags := flag.NewFlagSet("resume", flag.ExitOnError)
|
||||||
|
configPath := flags.String("c", "config.toml", "путь к файлу настроек")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.NArg() != 1 {
|
||||||
|
fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] <id>\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordID, ok := ident.Parse(flags.Arg(0))
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("настройки не читаются: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.Storage.Validate(); err != nil {
|
||||||
|
log.Fatalf("настройки хранилища негодны: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||||
|
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||||
|
ReadConnections: cfg.Storage.ReadConnections,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("база не открывается: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
log.Printf("база закрылась с отказом: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
records := sqliterepo.NewAudioRecordRepository(db)
|
||||||
|
events := sqliterepo.NewRecordEventRepository(db)
|
||||||
|
|
||||||
|
record, err := records.Get(recordID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("запись не читается: %v", err)
|
||||||
|
}
|
||||||
|
if !record.IsHalted() {
|
||||||
|
log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя
|
||||||
|
// у остановленной записи нет, и сверять признак захвата не с чем.
|
||||||
|
record.Resume()
|
||||||
|
if err := records.Save(record, ""); err != nil {
|
||||||
|
log.Fatalf("запись не сохраняется: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := events.Append(&entity.RecordEvent{
|
||||||
|
RecordID: recordID,
|
||||||
|
Origin: entity.EventOriginHuman,
|
||||||
|
Step: stepResume,
|
||||||
|
Outcome: entity.EventOutcomeResumed,
|
||||||
|
}); err != nil {
|
||||||
|
log.Fatalf("событие журнала записи не сохраняется: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State)
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
|
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
||||||
|
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
||||||
|
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||||
|
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
|
"git.vakhrushev.me/av/transcriber/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
|
||||||
|
// кончается ненулевым кодом выхода.
|
||||||
|
//
|
||||||
|
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
|
||||||
|
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
|
||||||
|
// распознавания.
|
||||||
|
func main() {
|
||||||
|
// Создаем структурированный логгер
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
if err := run(logger); err != nil {
|
||||||
|
logger.Error("Transcriber service failed to start", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(logger *slog.Logger) error {
|
||||||
|
// Parse command line flags
|
||||||
|
configPath := flag.String("c", "config.toml", "Path to config file")
|
||||||
|
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
|
||||||
|
}
|
||||||
|
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||||
|
|
||||||
|
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
|
||||||
|
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
|
||||||
|
// этом было бы неоткуда.
|
||||||
|
if err := cfg.Auth.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
||||||
|
// значило бы платить за настройку, которая не меняется.
|
||||||
|
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
||||||
|
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||||
|
// доходит вовсе, — а это разные поломки в разных местах.
|
||||||
|
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
|
||||||
|
|
||||||
|
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
|
||||||
|
// заголовка, которого сервис не читает, и имитация без годного логина роняют
|
||||||
|
// старт. Имена заголовков приходят проверке доводом — дом у них один,
|
||||||
|
// константы транспорта, — а пакет настроек транспорта не знает.
|
||||||
|
if err := cfg.ValidateTestHeaders(
|
||||||
|
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Представление предиката «подставляем ли» одно — непустота перечня, — и
|
||||||
|
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
|
||||||
|
// Второе выражение того же предиката разошлось бы с первым молча.
|
||||||
|
substitution := cfg.HeaderSubstitution()
|
||||||
|
if len(substitution) > 0 {
|
||||||
|
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
|
||||||
|
// спросив, — ровно то «может стать проблемой», ради которого заведён
|
||||||
|
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
|
||||||
|
// чужому архиву.
|
||||||
|
logger.Warn("Identity headers are substituted from configuration",
|
||||||
|
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
|
||||||
|
"capability", "access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
|
||||||
|
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||||
|
// ней значит остановить всякую запись первым же захватом.
|
||||||
|
if err := cfg.Pipeline.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.Storage.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||||
|
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||||
|
ReadConnections: cfg.Storage.ReadConnections,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
logger.Error("Failed to close the database", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Создаем контекст для graceful shutdown
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
|
||||||
|
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||||
|
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
|
||||||
|
// причине их становятся сотни.
|
||||||
|
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
store := sqliterepo.NewStore(cfg.Storage.DataDir)
|
||||||
|
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||||
|
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||||
|
repos := service.Repositories{
|
||||||
|
Records: recordRepo,
|
||||||
|
Files: fileRepo,
|
||||||
|
Texts: sqliterepo.NewTextRepository(db),
|
||||||
|
Structures: sqliterepo.NewStructureRepository(db),
|
||||||
|
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||||
|
Events: sqliterepo.NewRecordEventRepository(db),
|
||||||
|
}
|
||||||
|
users := sqliterepo.NewUserRepository(db)
|
||||||
|
|
||||||
|
// Создаем адаптеры
|
||||||
|
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||||
|
converter := ffmpegconv.NewFfmpegConverter()
|
||||||
|
|
||||||
|
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
|
||||||
|
Region: cfg.Yandex.ObjStorageRegion,
|
||||||
|
AccessKey: cfg.Yandex.ObjStorageAccessKey,
|
||||||
|
SecretKey: cfg.Yandex.ObjStorageSecretKey,
|
||||||
|
BucketName: cfg.Yandex.ObjStorageBucketName,
|
||||||
|
Endpoint: cfg.Yandex.ObjStorageEndpoint,
|
||||||
|
ApiKey: cfg.Yandex.SpeechKitAPIKey,
|
||||||
|
FolderID: cfg.Yandex.FolderID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create audio recognizer: %w", err)
|
||||||
|
}
|
||||||
|
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
|
||||||
|
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
|
||||||
|
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
|
||||||
|
// недоступности Yandex.
|
||||||
|
defer func() {
|
||||||
|
if err := recognizer.Close(); err != nil {
|
||||||
|
logger.Error("failed to close audio recognizer", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
transcribeService := service.NewTranscribeService(
|
||||||
|
repos,
|
||||||
|
metaviewer,
|
||||||
|
converter,
|
||||||
|
recognizer,
|
||||||
|
cfg.Pipeline.StuckLimits(),
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
|
||||||
|
// самой записи. Число приходит настройкой, ноль — законное значение.
|
||||||
|
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
pool.Start(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
|
||||||
|
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||||
|
|
||||||
|
appHandler := httpcontroller.NewAppHandler(
|
||||||
|
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||||
|
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||||
|
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||||
|
// наблюдения, правило неизвестного пути у раздачи приложения и область
|
||||||
|
// действия узнавания.
|
||||||
|
mounts := httpcontroller.ServiceMounts(
|
||||||
|
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
|
||||||
|
promhttp.Handler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
dist, appBuilt := web.Dist()
|
||||||
|
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
|
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
|
||||||
|
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
||||||
|
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
|
||||||
|
// модели угроз проекта.
|
||||||
|
ReadTimeout: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
serveErr := make(chan error, 1)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
serveErr <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Настраиваем обработку сигналов для graceful shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
|
||||||
|
logger.Info("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
|
// Ждем сигнал завершения либо отказ сервера
|
||||||
|
var startupErr error
|
||||||
|
select {
|
||||||
|
case <-sigChan:
|
||||||
|
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||||
|
case err := <-serveErr:
|
||||||
|
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
|
||||||
|
startupErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Останавливаем HTTP сервер
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||||
|
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
logger.Info("Shutting down HTTP server...")
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("HTTP server forced to shutdown", "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("HTTP server stopped gracefully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отменяем контекст для остановки воркеров
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
logger.Info("All workers stopped gracefully")
|
||||||
|
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
|
||||||
|
logger.Warn("Timeout reached, forcing shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Transcriber service stopped")
|
||||||
|
|
||||||
|
return startupErr
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Server configuration
|
|
||||||
[server]
|
|
||||||
port = 8080
|
|
||||||
shutdown_timeout = 5
|
|
||||||
force_shutdown_timeout = 20
|
|
||||||
|
|
||||||
# Storage configuration
|
|
||||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
|
||||||
[storage]
|
|
||||||
data_dir = "data"
|
|
||||||
|
|
||||||
# Yandex Cloud Configuration
|
|
||||||
[yandex]
|
|
||||||
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
|
||||||
folder_id = "your_folder_id_here"
|
|
||||||
|
|
||||||
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
|
|
||||||
speech_kit_api_key = "your_speech_kit_api_key_here"
|
|
||||||
|
|
||||||
# Object Storage (S3) configuration
|
|
||||||
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
|
|
||||||
object_storage_access_key_id = "your_access_key_id"
|
|
||||||
|
|
||||||
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
|
|
||||||
object_storage_secret_access_key = "your_secret_access_key"
|
|
||||||
|
|
||||||
# Имя бакета в Object Storage
|
|
||||||
object_storage_bucket_name = "your_bucket_name"
|
|
||||||
|
|
||||||
# Регион Object Storage
|
|
||||||
object_storage_region = "ru-central1"
|
|
||||||
|
|
||||||
# Endpoint Object Storage
|
|
||||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
|
||||||
|
|
||||||
# Telegram Bot Configuration
|
|
||||||
[telegram]
|
|
||||||
# Токен Telegram бота (получить у @BotFather в Telegram)
|
|
||||||
bot_token = "your_telegram_bot_token_here"
|
|
||||||
|
|
||||||
# Таймаут обновлений Telegram бота (в секундах)
|
|
||||||
update_timeout = 10
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Server configuration
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
shutdown_timeout = 5
|
||||||
|
force_shutdown_timeout = 20
|
||||||
|
|
||||||
|
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
|
||||||
|
#
|
||||||
|
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
|
||||||
|
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
|
||||||
|
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
|
||||||
|
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
|
||||||
|
# распознавателя признак не касается.
|
||||||
|
#
|
||||||
|
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
|
||||||
|
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
|
||||||
|
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
|
||||||
|
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
|
||||||
|
debug = false
|
||||||
|
|
||||||
|
# Хранилище: каталог данных и числа его базы.
|
||||||
|
#
|
||||||
|
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
|
||||||
|
# записей. Двух путей у хранилища не бывает.
|
||||||
|
[storage]
|
||||||
|
data_dir = "data"
|
||||||
|
|
||||||
|
# Сколько ждать занятую базу, миллисекунды. Положительное число.
|
||||||
|
#
|
||||||
|
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
|
||||||
|
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
|
||||||
|
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
|
||||||
|
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
|
||||||
|
# сразу» и потому не принимается.
|
||||||
|
busy_timeout_ms = 5000
|
||||||
|
|
||||||
|
# Сколько соединений держит читающий пул. Положительное число.
|
||||||
|
#
|
||||||
|
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
|
||||||
|
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||||
|
# Число выводят из числа воркеров плюс запас под запросы приложения.
|
||||||
|
#
|
||||||
|
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
|
||||||
|
# означало бы отказы по занятости на записи результата шага, то есть после
|
||||||
|
# оплаченной работы.
|
||||||
|
read_connections = 4
|
||||||
|
|
||||||
|
# Конвейер расшифровки.
|
||||||
|
[pipeline]
|
||||||
|
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
|
||||||
|
# работе запись и выбирает шаг по её рубежу.
|
||||||
|
#
|
||||||
|
# Ноль — законное значение, а не поломка: сервис поднимается, записи
|
||||||
|
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
|
||||||
|
# надо остановить, не роняя приём.
|
||||||
|
workers = 3
|
||||||
|
|
||||||
|
# Предел простоя записи там, где работу делаем мы сами, в минутах.
|
||||||
|
#
|
||||||
|
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
|
||||||
|
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
|
||||||
|
# многочасовая запись занимает на приведении, и это принято сознательно
|
||||||
|
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
|
||||||
|
# владельца, потому что остановка обратима и рубежа не стирает.
|
||||||
|
own_work_limit_minutes = 60
|
||||||
|
|
||||||
|
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
|
||||||
|
#
|
||||||
|
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
|
||||||
|
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
|
||||||
|
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
|
||||||
|
# вечно.
|
||||||
|
foreign_work_limit_minutes = 1440
|
||||||
|
|
||||||
|
# Yandex Cloud Configuration
|
||||||
|
[yandex]
|
||||||
|
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
||||||
|
folder_id = "your_folder_id_here"
|
||||||
|
|
||||||
|
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
|
||||||
|
speech_kit_api_key = "your_speech_kit_api_key_here"
|
||||||
|
|
||||||
|
# Object Storage (S3) configuration
|
||||||
|
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||||
|
object_storage_access_key_id = "your_access_key_id"
|
||||||
|
|
||||||
|
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||||
|
object_storage_secret_access_key = "your_secret_access_key"
|
||||||
|
|
||||||
|
# Имя бакета в Object Storage
|
||||||
|
object_storage_bucket_name = "your_bucket_name"
|
||||||
|
|
||||||
|
# Регион Object Storage
|
||||||
|
object_storage_region = "ru-central1"
|
||||||
|
|
||||||
|
# Endpoint Object Storage
|
||||||
|
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||||
|
|
||||||
|
# Кому сервис верит на входе.
|
||||||
|
#
|
||||||
|
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
|
||||||
|
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
|
||||||
|
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
|
||||||
|
# никому», то есть сервис, поднявшийся никого не узнающим.
|
||||||
|
[auth]
|
||||||
|
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
|
||||||
|
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
|
||||||
|
# шлёт запрос.
|
||||||
|
#
|
||||||
|
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
|
||||||
|
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
|
||||||
|
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
|
||||||
|
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
|
||||||
|
# Caddy, — узкий и свой.
|
||||||
|
trusted_proxies = ["172.20.0.0/24"]
|
||||||
|
|
||||||
|
# Локальный вход без Authelia — рецепт целиком.
|
||||||
|
#
|
||||||
|
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
|
||||||
|
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
|
||||||
|
# сервис: второго процесса и второго порта для этого не нужно, приложение
|
||||||
|
# открывают по адресу сервиса.
|
||||||
|
#
|
||||||
|
# Три правки этого файла сверху вниз, и других не нужно:
|
||||||
|
#
|
||||||
|
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
|
||||||
|
#
|
||||||
|
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
|
||||||
|
#
|
||||||
|
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
|
||||||
|
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
|
||||||
|
# журнала с адресом пира — по ней и опознаётся недостающая запись.
|
||||||
|
#
|
||||||
|
# 2. Поставить в секции [server] выше:
|
||||||
|
#
|
||||||
|
# debug = true
|
||||||
|
#
|
||||||
|
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
|
||||||
|
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
|
||||||
|
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
|
||||||
|
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
|
||||||
|
# узнает никого.
|
||||||
|
#
|
||||||
|
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
|
||||||
|
# учётной записи.
|
||||||
|
#
|
||||||
|
# Заполненная секция при debug = false роняет старт с именем ключа
|
||||||
|
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
|
||||||
|
# никак, а обе его прочтения — поломка.
|
||||||
|
#
|
||||||
|
# [auth.test_headers]
|
||||||
|
# Remote-User = "local"
|
||||||
|
# Remote-Name = "Разработчик"
|
||||||
|
# Remote-Email = "local@example.com"
|
||||||
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${USER_GID}" ]; then
|
if [ -z "${USER_GID}" ]; then
|
||||||
USER_GID="$(id -g ${USER})"
|
USER_GID="$(id -g "${USER}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${USER_UID}" ]; then
|
if [ -z "${USER_UID}" ]; then
|
||||||
USER_UID="$(id -u ${USER})"
|
USER_UID="$(id -u "${USER}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Change GID for USER?
|
# Change GID for USER?
|
||||||
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
|
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
|
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
|
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Change UID for USER?
|
# Change UID for USER?
|
||||||
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
|
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
|
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"canon": 14,
|
|
||||||
"migrations": "migrations"
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
- **Дата:** 2026-08-11
|
- **Дата:** 2026-08-11
|
||||||
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
|
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
|
||||||
разведки `pocketbase-admin-fit`
|
разведки `pocketbase-admin-fit`
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
@@ -67,6 +68,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
|
|||||||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||||||
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
|
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
|
||||||
переносятся по прежнему решению задачи `pocketbase-storage`.
|
переносятся по прежнему решению задачи `pocketbase-storage`.
|
||||||
|
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
|
||||||
|
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
|
||||||
|
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
|
||||||
|
[../database.md](../database.md), «Представление данных».
|
||||||
- `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с
|
- `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с
|
||||||
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
|
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
|
||||||
Что делать с сессией и где она живёт, решает уже не наш код.
|
Что делать с сессией и где она живёт, решает уже не наш код.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
- **Дата:** 2026-08-11
|
- **Дата:** 2026-08-11
|
||||||
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
|
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
|
||||||
разведки `job-queue-choice`
|
разведки `job-queue-choice`
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Кого пускать в сервис, решает правило провайдера, а не сервис
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Кого пускать, решает провайдер, а не сервис»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
|
||||||
|
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
|
||||||
|
выкладки, лежащая вне репозитория.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
|
||||||
|
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
|
||||||
|
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
|
||||||
|
человека», приглашает всех, кто уже есть у провайдера.
|
||||||
|
|
||||||
|
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
|
||||||
|
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
|
||||||
|
допуск, и решать его пришлось бы в двух местах согласованно.
|
||||||
|
|
||||||
|
Цена отказа названа в источнике и повторена в модели угроз:
|
||||||
|
|
||||||
|
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
|
||||||
|
> полагается так же, как полагается на обратный прокси в части панели
|
||||||
|
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
|
||||||
|
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
|
||||||
|
|
||||||
|
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
|
||||||
|
предложат снова, и без записанной причины она выглядит бесплатной.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
|
||||||
|
требует ни правки кода, ни выкладки.
|
||||||
|
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
|
||||||
|
записи — ни групп, ни ролей.
|
||||||
|
- `−` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
|
||||||
|
ревью её проверить не может: ни один проход не увидит, что клиент настроен
|
||||||
|
слишком широко.
|
||||||
|
- `−` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
|
||||||
|
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
|
||||||
|
- `−` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
|
||||||
|
записи и все расшифровки разом, а не одна учётная запись. Сузит это
|
||||||
|
`record-ownership`.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
- **Дата:** 2026-08-12
|
- **Дата:** 2026-08-12
|
||||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
|
||||||
|
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
|
||||||
|
разбирая ответ провайдера своими руками.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||||
|
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
|
||||||
|
сами и видны в панели. Это решение не пересматривается — пересматривается способ
|
||||||
|
до него дотянуться.
|
||||||
|
|
||||||
|
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
|
||||||
|
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
|
||||||
|
Остались три формы, и владелец выбрал первую:
|
||||||
|
|
||||||
|
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
|
||||||
|
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
|
||||||
|
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
|
||||||
|
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
|
||||||
|
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
|
||||||
|
> отложить вход до появления фронтенда.
|
||||||
|
|
||||||
|
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
|
||||||
|
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
|
||||||
|
записана.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
|
||||||
|
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
|
||||||
|
не «по духу».
|
||||||
|
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
|
||||||
|
под смену формата ответа доезжает само.
|
||||||
|
- `−` петля через собственный роутер: обработчик зовёт сервис, частью которого
|
||||||
|
сам является. Это новый для проекта вид узла, и его придётся объяснять на
|
||||||
|
каждом следующем изменении.
|
||||||
|
- `−` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
|
||||||
|
обмена доступна только кодом состояния.
|
||||||
|
- `−` роутер хранилища пришлось собирать **один раз** и держать полем: его
|
||||||
|
сборка вешает обработчики на само приложение и без идентификатора, поэтому
|
||||||
|
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
|
||||||
|
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
|
||||||
|
конвейером проходило по всем накопленным.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||||
|
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||||
|
пункт 3
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
|
||||||
|
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
|
||||||
|
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
|
||||||
|
предъявив сессию.
|
||||||
|
|
||||||
|
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
|
||||||
|
|
||||||
|
> Решение действует до разграничения доступа: задачи `oidc-login` и
|
||||||
|
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||||||
|
> записью.
|
||||||
|
|
||||||
|
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
|
||||||
|
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
|
||||||
|
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
|
||||||
|
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
|
||||||
|
записи — самому чувствительному, что есть у проекта.
|
||||||
|
|
||||||
|
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
|
||||||
|
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
|
||||||
|
строка журнала со ссылкой собирала бы половину ключа.
|
||||||
|
|
||||||
|
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
|
||||||
|
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
|
||||||
|
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
|
||||||
|
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
|
||||||
|
схемы.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
|
||||||
|
закрытия API это был последний анонимный путь к нему.
|
||||||
|
- `+` условие, названное прежней записью, отработало как задумано: решение
|
||||||
|
пересмотрено записью, а не молча.
|
||||||
|
- `−` ссылка усложнилась для потребителя: браузер с одной кукой файла не
|
||||||
|
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
|
||||||
|
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
|
||||||
|
- `−` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
|
||||||
|
ссылке он получит **любую** запись, а не только свою. Сужение приносит
|
||||||
|
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
|
||||||
|
угодно из вошедших», и это меньше, чем кажется.
|
||||||
|
- `−` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
|
||||||
|
короткий срок.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Сессия живёт семь суток и не продлевает саму себя
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||||
|
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||||
|
пункт 6
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
|
||||||
|
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
|
||||||
|
значение на новое, закрыт слоем приложения.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
|
||||||
|
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
|
||||||
|
значения продлевает себе доступ бессрочно, никуда не входя.
|
||||||
|
|
||||||
|
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
|
||||||
|
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
|
||||||
|
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
|
||||||
|
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
|
||||||
|
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
|
||||||
|
сохраняет его навсегда.
|
||||||
|
|
||||||
|
Отвергнуто и названо ценой:
|
||||||
|
|
||||||
|
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
|
||||||
|
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
|
||||||
|
> способ остановить того, кто тратит слишком много.
|
||||||
|
|
||||||
|
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
|
||||||
|
ждать, пока отзыв доедет.
|
||||||
|
|
||||||
|
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
|
||||||
|
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
|
||||||
|
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
|
||||||
|
новый срок, а хранилище продолжило выдавать прежний.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
|
||||||
|
семь суток; без этого он не доходил вовсе.
|
||||||
|
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
|
||||||
|
месте вместе со сроком куки.
|
||||||
|
- `−` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
|
||||||
|
нет, так что вход начинается с перехода по адресу входа руками.
|
||||||
|
- `−` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
|
||||||
|
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
|
||||||
|
своего адреса у этого нет.
|
||||||
|
- `−` закрытие продления сделано слоем приложения, а не настройкой коллекции:
|
||||||
|
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
|
||||||
|
Слой придётся помнить при всякой правке маршрутов.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
|
||||||
|
- **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в
|
||||||
|
спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма
|
||||||
|
шага осталась комментариями в `scripts/check-go-version.sh`
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
|
||||||
|
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
|
||||||
|
собирают. Потребитель у неё другой: тот, кто собирает.
|
||||||
|
|
||||||
|
Требование о согласованности объявленной версии Go живёт нормой в
|
||||||
|
`openspec/specs/toolchain/spec.md`, а не прозой в памятке. *Уточнено 2026-08-13:
|
||||||
|
файла по этому адресу больше нет, ссылка снята — capability упразднена, см.
|
||||||
|
статус записи.*
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
|
||||||
|
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
|
||||||
|
говорила «поведение системы здесь не описывается — нормативно оно живёт в
|
||||||
|
`openspec/specs/`». Согласованность версий сборки под это определение не
|
||||||
|
подходит, и натяжение признано прямо в источнике:
|
||||||
|
|
||||||
|
> Признаём натяжение: три существующие capability описывают поведение сервиса для
|
||||||
|
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
|
||||||
|
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
|
||||||
|
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
|
||||||
|
> как домен он здесь и назван.
|
||||||
|
|
||||||
|
Очевидные пути отвергнуты оба:
|
||||||
|
|
||||||
|
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
|
||||||
|
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
|
||||||
|
> меняется вместе.
|
||||||
|
>
|
||||||
|
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
|
||||||
|
> требование — «расхождение роняет набор проверок», — и требование без дома
|
||||||
|
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
|
||||||
|
> один раз жило в трёх документах и во всех трёх было неверным.
|
||||||
|
|
||||||
|
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
|
||||||
|
случился именно потому, что утверждение о версии сборки жило только прозой, в
|
||||||
|
трёх местах сразу, и никто не отвечал за его истинность.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
|
||||||
|
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
|
||||||
|
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
|
||||||
|
вторую спеку о том же.
|
||||||
|
- `−` определение capability в проекте стало шире, чем «поведение сервиса», и
|
||||||
|
граница теперь проходит по слову «домен». Следующее пограничное решение будет
|
||||||
|
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
|
||||||
|
- `−` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
|
||||||
|
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
|
||||||
|
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
|
||||||
|
остальных, либо асимметрия останется навсегда.
|
||||||
|
- `−` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
|
||||||
|
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
|
||||||
|
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
|
||||||
|
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
|
||||||
|
пересмотре его стоит перепроверить.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
|
||||||
|
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
|
||||||
|
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
|
||||||
|
|
||||||
|
Дословно из источника:
|
||||||
|
|
||||||
|
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
|
||||||
|
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
|
||||||
|
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
|
||||||
|
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
|
||||||
|
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
|
||||||
|
> не от коммита.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
|
||||||
|
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
|
||||||
|
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
|
||||||
|
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
|
||||||
|
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
|
||||||
|
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
|
||||||
|
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
|
||||||
|
|
||||||
|
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
|
||||||
|
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
|
||||||
|
сверять стало бы нечего. Отвергнута намеренно:
|
||||||
|
|
||||||
|
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
|
||||||
|
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
|
||||||
|
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
|
||||||
|
> Дороже и менее предсказуемо, чем строка сравнения.
|
||||||
|
|
||||||
|
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
|
||||||
|
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
|
||||||
|
числа сойдутся, а собирать будет пятое.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
|
||||||
|
`go`, шаг даёт тот же код выхода и тот же вывод.
|
||||||
|
- `+` требование «без сети» выполняется по построению, а не обещанием: под
|
||||||
|
`strace` шаг не делает ни одного сетевого вызова.
|
||||||
|
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
|
||||||
|
причиной.
|
||||||
|
- `−` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
|
||||||
|
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
|
||||||
|
образца ломает смежный случай беззвучно.
|
||||||
|
- `−` запрет `toolchain` придётся снять или пересмотреть, если зависимость
|
||||||
|
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
|
||||||
|
пересматривается, а не обходится.
|
||||||
|
- `−` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
|
||||||
|
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
|
||||||
|
сошлось. Остаток объявлен и уехал отдельной задачей.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Намерение объявляется признаком, а не выводится из ключа доступа
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-13
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
|
||||||
|
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token`
|
||||||
|
означает только доступ. Признак **обязателен**: умолчания у него нет, и файл
|
||||||
|
настроек без него негоден — сервис выходит с ошибкой настройки, назвав
|
||||||
|
недостающий ключ.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и
|
||||||
|
«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения
|
||||||
|
падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ
|
||||||
|
выглядел для сервиса как решение владельца.
|
||||||
|
|
||||||
|
Умолчания у признака нет, и это **намеренный отказ от очевидного подхода** —
|
||||||
|
булев ключ обычно заводят с умолчанием. Цитата из источника:
|
||||||
|
|
||||||
|
> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы
|
||||||
|
> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
|
||||||
|
> стороны, и любое умолчание делает одну из двух ошибок тихой.
|
||||||
|
|
||||||
|
Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь»,
|
||||||
|
и разница между объявленным и угаданным намерением исчезала бы ровно там, где её
|
||||||
|
завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то
|
||||||
|
есть дал бы исход, против которого написано само требование.
|
||||||
|
|
||||||
|
Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined`
|
||||||
|
отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих
|
||||||
|
одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и
|
||||||
|
уехал к потребителям, где `nil` уже невозможен, но выглядит возможным.
|
||||||
|
|
||||||
|
Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата
|
||||||
|
из источника:
|
||||||
|
|
||||||
|
> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого
|
||||||
|
> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со
|
||||||
|
> значением.
|
||||||
|
|
||||||
|
Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция
|
||||||
|
настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка
|
||||||
|
не принадлежит ни одной заведённой capability.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет
|
||||||
|
сервис работать в половину силы;
|
||||||
|
- `+` выключенный вход перестал быть поводом для предупреждения: решение
|
||||||
|
владельца сообщается записью «к сведению», а предупреждение осталось за тем,
|
||||||
|
чего владелец не выбирал, — недоступностью Telegram;
|
||||||
|
- `+` оборванная строка секретного ключа больше не уносит значение в журнал
|
||||||
|
контейнера;
|
||||||
|
- `−` **порядок выкладки стал обязательным**: шаблон настроек обязан получить
|
||||||
|
признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт
|
||||||
|
в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там
|
||||||
|
по ключу, а не по файлу целиком;
|
||||||
|
- `−` один путь молчаливой потери бота остался: признак, ошибочно собранный
|
||||||
|
как «выключен», отличим от решения владельца только записью журнала. Признак
|
||||||
|
поднятости входа тут не помощник — он равен нулю и при недоступности Telegram;
|
||||||
|
- `−` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
|
||||||
|
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
|
||||||
|
помеченного необратимым.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Недоступность Telegram подъёму сервиса не мешает
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-13
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
|
||||||
|
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Старт роняет только один исход сборки клиента бота — ответ Telegram «такого бота
|
||||||
|
нет». Всё прочее, включая недоступность Telegram и истёкший срок ожидания, даёт
|
||||||
|
подъём без Telegram: сервис работает по HTTP и говорит о неподнятом входе
|
||||||
|
записью журнала и метрикой.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Очевидный подход был обратный, и он же стоял в первой редакции дизайна: любой
|
||||||
|
отказ сборки бота роняет старт, потому что «сервис, молча потерявший бота после
|
||||||
|
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
|
||||||
|
бы неоткуда».
|
||||||
|
|
||||||
|
Ревью кода показало цену этого подхода. Цитата из источника:
|
||||||
|
|
||||||
|
> при `api.telegram.org`, отвечающем молчанием, процесс висит в `getMe` без
|
||||||
|
> ограничения времени: HTTP-вход не открыт, панель не открыта, `/health` не
|
||||||
|
> отвечает вовсе, воркеры не запущены, в журнале — ни строки.
|
||||||
|
|
||||||
|
То есть перезапуск в минуту чужой аварии оставлял без работы приём по HTTP,
|
||||||
|
панель и конвейер, которому Telegram не нужен вовсе. Паспорт при этом называет
|
||||||
|
основным входом приложение, а бот и HTTP API — дополняющими его.
|
||||||
|
|
||||||
|
Тем же ревью снят довод, на котором держалась прежняя редакция. Она утверждала,
|
||||||
|
что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Цитата
|
||||||
|
из источника:
|
||||||
|
|
||||||
|
> Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный
|
||||||
|
> отказ — нашим после чистки, и одно от другого отделяется проверкой типа.
|
||||||
|
> Утверждение держалось на незнании библиотеки, а не на её устройстве.
|
||||||
|
|
||||||
|
Решение владельца: недоступность Telegram на старт приложения не влияет.
|
||||||
|
|
||||||
|
Из него следует второе, без которого оно невыполнимо: ожидание при сборке
|
||||||
|
ограничено сроком. Пока срока не было, недоступность не отличалась от подъёма.
|
||||||
|
Срок стоит только на сборке — длинный опрос им не ограничен, иначе он рвался бы
|
||||||
|
на каждом круге.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` авария Telegram не роняет основной вход, панель и конвейер: сервис
|
||||||
|
поднимается и обрабатывает уже принятое;
|
||||||
|
- `+` опечатка в токене по-прежнему заметна: Telegram отвечает отказом, и старт
|
||||||
|
не проходит;
|
||||||
|
- `+` молчащий Telegram больше не вешает подъём бессрочно;
|
||||||
|
- `−` долгая недоступность Telegram даёт сервис, работающий без бота, а
|
||||||
|
отправители в это время не получают ответов. Замена «узнать неоткуда» —
|
||||||
|
запись журнала при старте и признак поднятости входа метрикой;
|
||||||
|
- `−` токен, не разбирающийся как часть адреса (перенос строки из шаблона
|
||||||
|
выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай
|
||||||
|
попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала,
|
||||||
|
а не отказом старта.
|
||||||
|
|
||||||
|
*Уточнено 2026-08-13:* исходов сборки клиента, роняющих старт, стало два —
|
||||||
|
к ответу «такого бота нет» добавился пустой ключ доступа при включённом входе.
|
||||||
|
Решение это не меняет: пустой ключ ошибкой настройки и был, просто прежде он
|
||||||
|
выражал ещё и отказ от входа, а теперь отказ выражает признак `telegram.enabled`
|
||||||
|
и до сборки клиента не доходит вовсе. Недоступность Telegram по-прежнему подъёму
|
||||||
|
не мешает — ровно как решено здесь. Разведение двух значений — отдельная запись,
|
||||||
|
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-14
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
|
||||||
|
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
|
||||||
|
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
|
||||||
|
никак: ни владельцем панели, ни самим человеком.
|
||||||
|
|
||||||
|
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
|
||||||
|
предложенных способов.
|
||||||
|
|
||||||
|
Дословно из источника:
|
||||||
|
|
||||||
|
> **Что делать с записями удалённого пользователя?** Связь при выключенном
|
||||||
|
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
|
||||||
|
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
|
||||||
|
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
|
||||||
|
> признать потерю ценой и записать её.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
|
||||||
|
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
|
||||||
|
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
|
||||||
|
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
|
||||||
|
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
|
||||||
|
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
|
||||||
|
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
|
||||||
|
значения не остаётся нигде.
|
||||||
|
|
||||||
|
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
|
||||||
|
было неверным, и это выяснилось на ревью дизайна.
|
||||||
|
|
||||||
|
## Чем платим
|
||||||
|
|
||||||
|
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
|
||||||
|
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
|
||||||
|
|
||||||
|
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
|
||||||
|
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
|
||||||
|
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
|
||||||
|
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
|
||||||
|
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
|
||||||
|
|
||||||
|
## Что рассматривалось и отвергнуто
|
||||||
|
|
||||||
|
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
|
||||||
|
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
|
||||||
|
строка, и целостность, ради которой выбрана связь, теряется.
|
||||||
|
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
|
||||||
|
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
|
||||||
|
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
|
||||||
|
|
||||||
|
## Связанное
|
||||||
|
|
||||||
|
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||||
|
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
|
||||||
|
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
|
||||||
|
удаляется».
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Остановка записи — признак, а не рубеж
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-14
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
|
||||||
|
раздел «Остановка — признак, а не рубеж»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Прежние состояния отказа и смерти (`failed`, `dead`) схлопнуты в **признак
|
||||||
|
остановки** с причиной: `halted_at`, `halt_reason`, `error_text`. Достигнутый
|
||||||
|
рубеж при остановке не стирается, и снятие признака продолжает работу с того
|
||||||
|
места, где запись встала.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата источника:
|
||||||
|
|
||||||
|
> **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
|
||||||
|
> остановки, массовый перезапуск после выкатки правки делается одним
|
||||||
|
> обновлением, а различие «мы рассудили» против «мы перестали пробовать»
|
||||||
|
> остаётся причиной, которую человек читает.
|
||||||
|
|
||||||
|
Отвергнуты два варианта, оба с названной ценой:
|
||||||
|
|
||||||
|
> **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
|
||||||
|
> закрыт схемой, и каждая новая причина стоила бы необратимого шага.
|
||||||
|
>
|
||||||
|
> **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
|
||||||
|
> руками в панели остаётся единственным способом вернуть запись в работу, и
|
||||||
|
> делается он наугад.
|
||||||
|
|
||||||
|
Прежняя модель описана решением
|
||||||
|
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md):
|
||||||
|
там состояние «мертва» заводилось взамен признака `is_error`, и довод был тот
|
||||||
|
же — «два способа вывести задачу из выборки расходятся». Довод устоял, а
|
||||||
|
носитель сменился: теперь единственный способ вывести запись из выборки — этот
|
||||||
|
признак, и состояние его больше не дублирует.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого
|
||||||
|
рубежа, а не начинает конвейер заново.
|
||||||
|
- `+` новая причина остановки стоит значения в закрытом перечне причин, а не
|
||||||
|
нового состояния и не нового шага схемы.
|
||||||
|
- `+` массовый возврат в работу после выкатки правки делается одним обновлением
|
||||||
|
колонки.
|
||||||
|
- `−` в выборке захвата появилось четвёртое условие, и рубеж перестал быть
|
||||||
|
единственным, что выводит запись из работы: читать состояние записи теперь
|
||||||
|
надо двумя полями.
|
||||||
|
- `−` перечень причин закрыт схемой, то есть новая причина всё же требует шага
|
||||||
|
схемы — дешевле прежнего, но не бесплатно.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Ответ распознавателя хранится дословно, двоичной формой и вложением
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-14
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
|
||||||
|
раздел «Сырой ответ провайдера хранится вложением, а не колонкой»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей
|
||||||
|
двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток
|
||||||
|
распознавания, а не колонкой.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата источника:
|
||||||
|
|
||||||
|
> Хранится он вообще потому, что **результат операции у провайдера не
|
||||||
|
> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
|
||||||
|
> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и
|
||||||
|
> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит
|
||||||
|
> денег за каждую запись.
|
||||||
|
|
||||||
|
Вложением, а не колонкой:
|
||||||
|
|
||||||
|
> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а
|
||||||
|
> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой,
|
||||||
|
> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в
|
||||||
|
> перечне колонок захвата сегодня.
|
||||||
|
|
||||||
|
Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер:
|
||||||
|
текстовое представление собирается по нашей скомпилированной схеме и **молча
|
||||||
|
выбрасывает поля, которых в ней нет**, а провайдер добавляет их без
|
||||||
|
предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись
|
||||||
|
и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с
|
||||||
|
говорящим станет доступна, когда мы научимся её читать.
|
||||||
|
- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память.
|
||||||
|
- `−` **формат файла на диске объявлен необратимым**: сохранённое не читается
|
||||||
|
глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без
|
||||||
|
сервиса нельзя вовсе.
|
||||||
|
- `−` каталог данных растёт быстрее прежнего: ответ многословнее самой
|
||||||
|
расшифровки — несёт альтернативы, время каждого слова и разбор говорящих.
|
||||||
|
Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на
|
||||||
|
деле у шестичасовой записи, не мерил никто.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Предел простоя остаётся часом, хотя он короче самой работы
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-14
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
|
||||||
|
раздел «Сторожей двое, и предела времени — два числа»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Сторож застревания ограничивает время записи в рубеже двумя числами: **час** на
|
||||||
|
свою работу, **сутки** на ожидание чужой операции. Час меньше времени, которое
|
||||||
|
многочасовая запись занимает на приведении, и это принято сознательно.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Ревью дизайна показало, что число противоречит расчётному потолку записи:
|
||||||
|
|
||||||
|
> Расчётный потолок записи — шесть часов, приведение такой записи идёт дольше
|
||||||
|
> часа по построению, а срок захвата шага приведения стоит сегодня восемью
|
||||||
|
> часами. Значит длинная запись, отказавшая один раз и ждущая повтора дольше
|
||||||
|
> часа, будет остановлена сторожем застревания вместо расшифровки.
|
||||||
|
|
||||||
|
Предложено было вывести предел из срока захвата — двенадцать часов на свою
|
||||||
|
работу. Владелец решил оставить час, и довод записан цитатой:
|
||||||
|
|
||||||
|
> Оставляем час. Тут нужно принять, что это скорее про зависшую задачу, потому
|
||||||
|
> что пока идёт обработка даже длинной записи мы всегда можем проверить, жив ли
|
||||||
|
> процесс конвертера.
|
||||||
|
|
||||||
|
Довод держится на том, что остановка теперь **обратима**: она не стирает рубежа,
|
||||||
|
и снятие признака возвращает запись туда, где она стояла (см.
|
||||||
|
[ADR-2026-08-14-halt-is-a-flag-not-a-stage](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md)).
|
||||||
|
Цена ложной остановки поэтому равна одному движению владельца, а не потерянной
|
||||||
|
записи.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` зависшая запись обнаруживается за час, а не за восемь.
|
||||||
|
- `+` число живёт в настройках и правится без шага схемы, если класс начнёт
|
||||||
|
всплывать.
|
||||||
|
- `−` длинная запись, отказавшая один раз и прождавшая повтора дольше часа,
|
||||||
|
останавливается как застрявшая — владельцу приходится снимать признак руками.
|
||||||
|
- `−` предел этот работает только по записи, вернувшейся в выборку. У держателя,
|
||||||
|
погибшего жёстко, запись невидима сторожу до истечения **срока захвата** её
|
||||||
|
рубежа, то есть восьми часов у приведения; замер и оговорка стоят строкой в
|
||||||
|
`database.md`.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Приложение живёт своим пространством адресов, а не общим с хранилищем
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||||
|
раздел «Переезд в `/app/`, а слой сессии — на корень»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой
|
||||||
|
предъявления сессии повешен на **группу корня**, а не на перечень адресов.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы
|
||||||
|
адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка.
|
||||||
|
Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки
|
||||||
|
вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом
|
||||||
|
начнёт отвечать не тот обработчик.
|
||||||
|
|
||||||
|
Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*`
|
||||||
|
решением от 2026-08-12.
|
||||||
|
|
||||||
|
Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом
|
||||||
|
приложения, и забытый в нём адрес молча перестал бы принимать куку».
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших
|
||||||
|
адресов больше не задевает;
|
||||||
|
- `+` новый адрес приложения получает слой предъявления по построению, а не по
|
||||||
|
памяти того, кто его добавил;
|
||||||
|
- `−` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо
|
||||||
|
одного: `/api/`, `/app/`, `/auth/` и `/_/`;
|
||||||
|
- `−` ограничитель частоты хранилища, настроенный на его собственный корень,
|
||||||
|
наших адресов не покрывает — своё правило заводится нами, и его включение
|
||||||
|
вводит в действие заодно умолчательные правила хранилища;
|
||||||
|
- `−` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено
|
||||||
|
стадией — на сервере данных нет, внешней программы на прежнем контракте не
|
||||||
|
существует.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Страница архива задаётся ключом, а не номером
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||||
|
раздел «Страница задаётся ключом, а не номером»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
|
||||||
|
заведения и идентификатор». Номер страницы отвергнут.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
|
||||||
|
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
|
||||||
|
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
|
||||||
|
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
|
||||||
|
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
|
||||||
|
котором записи нет.»
|
||||||
|
|
||||||
|
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
|
||||||
|
между ними одним лишь временем не определён.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
|
||||||
|
пропуска;
|
||||||
|
- `+` порядок между записями с равным временем устойчив;
|
||||||
|
- `−` экран с нумерацией страниц так не сделать — листать можно только
|
||||||
|
«дальше». Архиву это не нужно;
|
||||||
|
- `−` ключ приходит от клиента и потому разбирается: время приводится к виду
|
||||||
|
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
|
||||||
|
истину или ложь.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Node зовётся контейнером, а не ставится на машину разработчика
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
|
||||||
|
раздел «Node не ставится на машину, а зовётся контейнером»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
|
||||||
|
а не вызывает их из `PATH`:
|
||||||
|
|
||||||
|
> Требованием к машине разработчика становится docker, которым и так собирается
|
||||||
|
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
|
||||||
|
> вовсе.
|
||||||
|
|
||||||
|
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
|
||||||
|
вторым числом в `Taskfile.yml`.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Довод в дизайне назван прямо:
|
||||||
|
|
||||||
|
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
|
||||||
|
> машине разработчика и версия в образе — два разных числа, и собранное ими
|
||||||
|
> приложение различается ровно тогда, когда различаются они.
|
||||||
|
|
||||||
|
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
|
||||||
|
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
|
||||||
|
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
|
||||||
|
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
|
||||||
|
стоит.
|
||||||
|
|
||||||
|
## Почему это ADR
|
||||||
|
|
||||||
|
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
|
||||||
|
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
|
||||||
|
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
|
||||||
|
`npm run build`».
|
||||||
|
|
||||||
|
## Что это меняет в прежнем решении
|
||||||
|
|
||||||
|
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
|
||||||
|
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
|
||||||
|
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
|
||||||
|
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
|
||||||
|
записи нет: заменена не она, а толкование одного её последствия.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` версия сборочного окружения живёт **одним** местом — ступенью
|
||||||
|
`Dockerfile`, — и своего шага сверки ей не нужно.
|
||||||
|
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
|
||||||
|
совпадает окружение сборки, а не потому что «обычно совпадает».
|
||||||
|
- `−` **набор проверок перестаёт работать без docker**, и отказ этот приходит
|
||||||
|
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
|
||||||
|
шагов в наборе становится два вместо одного.
|
||||||
|
- `−` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
|
||||||
|
установщика уводится наружу — обе частности обязательны: без них собранное
|
||||||
|
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
|
||||||
|
- `−` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
|
||||||
|
не растёт (ступень в рабочий слой не копируется), а время сборки решением
|
||||||
|
владельца от 2026-08-15 не замеряется вовсе.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Обязательность владельца держит схема, а не приём
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||||
|
разделы «Схема теряет только необязательность владельца» и «Владелец записи
|
||||||
|
перестаёт быть необязательным и в модели»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
|
||||||
|
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
|
||||||
|
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
|
||||||
|
вместо ссылки, которой позволено отсутствовать.
|
||||||
|
|
||||||
|
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
|
||||||
|
строки надо запросом до выкладки.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата источника:
|
||||||
|
|
||||||
|
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
|
||||||
|
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
|
||||||
|
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
|
||||||
|
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
|
||||||
|
|
||||||
|
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
|
||||||
|
допускать пустое значение… Обязательность для приёма по HTTP держит сама
|
||||||
|
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
|
||||||
|
них владельца не было по построению. Вход убран
|
||||||
|
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
|
||||||
|
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
|
||||||
|
владельца в боевой базе нет.
|
||||||
|
|
||||||
|
Про непроверку существующих строк цитата источника:
|
||||||
|
|
||||||
|
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
|
||||||
|
> оракулом: хранилище держит обязательность связи проверкой записи при
|
||||||
|
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
|
||||||
|
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
|
||||||
|
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
|
||||||
|
> первым шагом плана перехода.
|
||||||
|
|
||||||
|
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
|
||||||
|
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
|
||||||
|
**спрашивать** ничьим именем.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
|
||||||
|
модели, ни в отборе.
|
||||||
|
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
|
||||||
|
и приём, — одним, а не двумя.
|
||||||
|
- `−` откат шага возвращает необязательность, но операционно недостижим: команд
|
||||||
|
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
|
||||||
|
- `−` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
|
||||||
|
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
|
||||||
|
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
|
||||||
|
событий, только строка в логе контейнера.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||||
|
раздел «Три новых колонки записи и один шаг схемы»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
|
||||||
|
уже есть у строки её файла. Равенство между ними не поддерживается никем —
|
||||||
|
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Обе величины показываются в списке, а список по норме `storage` читается без
|
||||||
|
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
|
||||||
|
держать равными. Решением владельца колонки остались, а равенство объявлено
|
||||||
|
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
|
||||||
|
файле — величины той копии, которой файл является сейчас». Уточнение
|
||||||
|
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
|
||||||
|
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
|
||||||
|
«что лежит сейчас».
|
||||||
|
|
||||||
|
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
|
||||||
|
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
|
||||||
|
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
|
||||||
|
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||||
|
метаданными отвергается отказом и не заводится.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` страница списка не читает по строке файла на каждую запись;
|
||||||
|
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
|
||||||
|
- `−` в применённом шаге схемы навсегда остаются две колонки, повторяющие
|
||||||
|
величины строки файла; расхождение между ними — не поломка, и заметить его
|
||||||
|
нечем;
|
||||||
|
- `−` запись, заведённая рукой в панели без величин, покажет человеку ноль.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Метка убранного входа не выставляется вовсе, а не обнуляется
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||||
|
раздел «Метка убранного входа не выставляется вовсе»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
|
||||||
|
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
|
||||||
|
меткой `telegram` не появляется после выкладки.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата источника:
|
||||||
|
|
||||||
|
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
|
||||||
|
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
|
||||||
|
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
|
||||||
|
> месте.
|
||||||
|
|
||||||
|
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
|
||||||
|
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
|
||||||
|
означает именно неподнятый вход, а не отсутствующий.
|
||||||
|
|
||||||
|
С единственным оставшимся входом проверяемым осталось только **множество меток**:
|
||||||
|
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
|
||||||
|
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
|
||||||
|
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
|
||||||
|
когда входов у сервиса станет больше одного.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
|
||||||
|
авария.
|
||||||
|
- `−` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
|
||||||
|
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
|
||||||
|
Владельцу, если такой отбор был заведён, править его руками.
|
||||||
|
- `−` требование «различать поднятый и неподнятый» стало непроверяемым до
|
||||||
|
возвращения второго входа, и это сказано в самом требовании прямо.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Вход Telegram убран целиком, а не выключен признаком
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||||
|
разделы «Context» и «Формы решения, между которыми выбирали»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
|
||||||
|
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
|
||||||
|
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
|
||||||
|
связью чата с учётной записью.
|
||||||
|
|
||||||
|
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
|
||||||
|
значение `telegram` перечня источников остаются в схеме вместе с записями,
|
||||||
|
которые их заполнили.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата источника:
|
||||||
|
|
||||||
|
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
|
||||||
|
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
|
||||||
|
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
|
||||||
|
> записи заводятся, правило «каждая запись принадлежит человеку» действует
|
||||||
|
> наполовину.
|
||||||
|
|
||||||
|
Отвергнуты две формы решения, обе с названной ценой:
|
||||||
|
|
||||||
|
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
|
||||||
|
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
|
||||||
|
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
|
||||||
|
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
|
||||||
|
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
|
||||||
|
> следующая задача.
|
||||||
|
>
|
||||||
|
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
|
||||||
|
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
|
||||||
|
> понятие «канал уведомления владельца», которое тут же переделает задача
|
||||||
|
> `ntfy-delivery`.
|
||||||
|
|
||||||
|
Решениями, которые это изменение отменяет, были
|
||||||
|
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
|
||||||
|
и
|
||||||
|
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
|
||||||
|
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
|
||||||
|
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
|
||||||
|
чей внешний собеседник недоступен».
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
|
||||||
|
доступа, конвейера и хранилища.
|
||||||
|
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
|
||||||
|
утечки токена, которые проект закрывал двумя задачами.
|
||||||
|
- `−` у сервиса не осталось входа, которым человек может воспользоваться:
|
||||||
|
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
|
||||||
|
собранным руками запросом с сессией из браузера. Владелец окно принял.
|
||||||
|
- `−` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
|
||||||
|
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
|
||||||
|
- `−` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
|
||||||
|
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
|
||||||
|
Отправитель голосового не получит ни ответа, ни отказа.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-22
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
|
||||||
|
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
|
||||||
|
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
|
||||||
|
самого соединения. Учётная запись заводится первым обращением с новым логином и
|
||||||
|
находится по нему же дальше.
|
||||||
|
|
||||||
|
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
|
||||||
|
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
|
||||||
|
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
|
||||||
|
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
|
||||||
|
сессии.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата из источника, раздел Р3:
|
||||||
|
|
||||||
|
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
|
||||||
|
> заголовку, который прокси поставил, сходив к Authelia.
|
||||||
|
>
|
||||||
|
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
|
||||||
|
> значение, живущее семь суток, отозванный у провайдера человек работал до
|
||||||
|
> истечения этого значения, и другого канала отзыва не было.
|
||||||
|
|
||||||
|
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
|
||||||
|
заголовком:
|
||||||
|
|
||||||
|
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
|
||||||
|
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
|
||||||
|
> барьер вовсе.
|
||||||
|
|
||||||
|
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
|
||||||
|
продолжение (Р3):
|
||||||
|
|
||||||
|
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
|
||||||
|
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
|
||||||
|
> суток вернулись бы вместе с ним.
|
||||||
|
|
||||||
|
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
|
||||||
|
соседним сервисам того же контура, а правила для этого сервиса там не было
|
||||||
|
вовсе — он не выложен.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
|
||||||
|
Authelia судит каждое обращение.
|
||||||
|
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
|
||||||
|
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
|
||||||
|
секрета.
|
||||||
|
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
|
||||||
|
накопившихся задач о его механике.
|
||||||
|
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
|
||||||
|
знака перестаёт существовать.
|
||||||
|
- `−` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
|
||||||
|
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
|
||||||
|
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
|
||||||
|
проверить отсюда нечем: правило живёт в `pet-project-server`.
|
||||||
|
- `−` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
|
||||||
|
прежнего. Неизменяемого признака заголовок не приносит; не допускать
|
||||||
|
переиспользования — работа провайдера. Обратная сторона: переименование
|
||||||
|
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
|
||||||
|
убрать.
|
||||||
|
- `−` Поиск учётной записи идёт на каждом запросе к области приложения вместо
|
||||||
|
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
|
||||||
|
требовалось — сервисом пользуются единицы человек.
|
||||||
|
- `−` Половина работы лежит вне репозитория: до того как правило прокси и правило
|
||||||
|
Authelia на домен заведут, сервис не узнает никого.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-22
|
||||||
|
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
|
||||||
|
записка разведки о выборе хранилища
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
|
||||||
|
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
|
||||||
|
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
|
||||||
|
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
|
||||||
|
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
|
||||||
|
базе.
|
||||||
|
|
||||||
|
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
|
||||||
|
названо здесь.
|
||||||
|
|
||||||
|
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
|
||||||
|
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
|
||||||
|
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
|
||||||
|
выполняет сама.
|
||||||
|
|
||||||
|
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
|
||||||
|
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
|
||||||
|
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
|
||||||
|
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
|
||||||
|
делает запрос к базе руками» — читается этой сменой.
|
||||||
|
|
||||||
|
Доводы обоих решений записаны в
|
||||||
|
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
|
||||||
|
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
|
||||||
|
ничем, а возврат в работу делает подкоманда оснастки».
|
||||||
|
|
||||||
|
Запись заменяет три:
|
||||||
|
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
|
||||||
|
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
|
||||||
|
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
|
||||||
|
|
||||||
|
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
|
||||||
|
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
|
||||||
|
очереди по-прежнему не берём: замер, которым это решено, снят на
|
||||||
|
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
|
||||||
|
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
|
||||||
|
Приложение остаётся в своём корне `/app/`
|
||||||
|
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
|
||||||
|
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
|
||||||
|
полем коллекции и коротким токеном.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
|
||||||
|
этом коде. Цитата из источника:
|
||||||
|
|
||||||
|
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
|
||||||
|
> шесть ролей сразу, и только две из них про хранение.
|
||||||
|
|
||||||
|
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
|
||||||
|
|
||||||
|
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
|
||||||
|
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
|
||||||
|
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
|
||||||
|
> которым отвергнут отвергнутый вариант, перестал быть верным.
|
||||||
|
|
||||||
|
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
|
||||||
|
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
|
||||||
|
вторая порция работы стоит дороже, чем то, что она сберегает.
|
||||||
|
|
||||||
|
Обстоятельство, которое назначило момент:
|
||||||
|
|
||||||
|
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
|
||||||
|
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
|
||||||
|
> кодом на чужих типах с каждой задачей.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
|
||||||
|
дефектом `/%5f/` из [../security.md](../security.md), а пространство
|
||||||
|
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
|
||||||
|
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
|
||||||
|
панели.
|
||||||
|
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
|
||||||
|
удаление записи и вторая копия рядом становятся обычной работой с файлами.
|
||||||
|
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
|
||||||
|
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
|
||||||
|
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
|
||||||
|
- `−` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
|
||||||
|
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
|
||||||
|
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
|
||||||
|
улучшением, а заменой утраченного инструмента.
|
||||||
|
- `−` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
|
||||||
|
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
|
||||||
|
с этого дня.
|
||||||
|
- `−` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
|
||||||
|
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
|
||||||
|
нулевой.
|
||||||
|
- `−` 1497 строк контроллера и 3075 строк его проверок написаны на
|
||||||
|
`*core.RequestEvent` и переписываются целиком.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-23
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||||
|
решение 4
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
|
||||||
|
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
|
||||||
|
в источнике дословно:
|
||||||
|
|
||||||
|
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
|
||||||
|
|
||||||
|
Рассматривалось требование, чтобы при включённом предохранителе перечень
|
||||||
|
доверенных адресов состоял только из петлевых записей; оно снято вместе с
|
||||||
|
предикатом «петлевая запись», который заводился ровно ради него.
|
||||||
|
|
||||||
|
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
|
||||||
|
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
|
||||||
|
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
|
||||||
|
круга тех, кто мог назваться кем угодно, не расширяет».
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Решение покупает работоспособность отладочного входа там, где адрес пира не
|
||||||
|
петлевой:
|
||||||
|
|
||||||
|
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
|
||||||
|
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
|
||||||
|
> переставляет перечень доверенных адресов на петлевой: петлевые записи
|
||||||
|
> добавляются к тем, что в нём уже стоят.
|
||||||
|
|
||||||
|
Цена названа в источнике прямо, и владелец принял именно её:
|
||||||
|
|
||||||
|
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
|
||||||
|
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
|
||||||
|
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
|
||||||
|
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
|
||||||
|
> пришедший обычным путём**.
|
||||||
|
|
||||||
|
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
|
||||||
|
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
|
||||||
|
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
|
||||||
|
копируют с машины разработчика.
|
||||||
|
|
||||||
|
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
|
||||||
|
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
|
||||||
|
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
|
||||||
|
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
|
||||||
|
предохранителя, которого решением владельца нет».
|
||||||
|
|
||||||
|
## Почему это ADR
|
||||||
|
|
||||||
|
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
|
||||||
|
при включённом отладочном входе — первое, что предлагает всякий, кто читает
|
||||||
|
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
|
||||||
|
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
|
||||||
|
эту дыру находит заново.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
|
||||||
|
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
|
||||||
|
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
|
||||||
|
нему добавляются.
|
||||||
|
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
|
||||||
|
проверки.
|
||||||
|
- `−` **Машина не исключает боевую поломку «конфиг с `debug = true` и
|
||||||
|
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
|
||||||
|
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
|
||||||
|
угроз, [security.md](../security.md), «Периметр», и в спеке
|
||||||
|
[access](../../openspec/specs/access/spec.md).
|
||||||
|
- `−` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
|
||||||
|
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
|
||||||
|
правило прокси про заголовки `Remote-*`.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-23
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||||
|
решения 1, 6, 7 и 10
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
|
||||||
|
цепочки корня приложения, а не вспомогательным процессом рядом:
|
||||||
|
|
||||||
|
> отдельный слой цепочки корня приложения, стоящий **перед**
|
||||||
|
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
|
||||||
|
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
|
||||||
|
> выдаёт, в контекст не пишет.
|
||||||
|
|
||||||
|
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
|
||||||
|
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
|
||||||
|
|
||||||
|
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
|
||||||
|
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
|
||||||
|
> входить локально не остаётся тоже.
|
||||||
|
|
||||||
|
Имена заголовков служат именами ключей секции, но набор принимаемых имён
|
||||||
|
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
|
||||||
|
ни с одним из них, роняет старт.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
|
||||||
|
Дизайн записал это целью:
|
||||||
|
|
||||||
|
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
|
||||||
|
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
|
||||||
|
> настроек, и ничем больше.
|
||||||
|
|
||||||
|
Довод в пользу слоя перед узнаванием, а не внутри него:
|
||||||
|
|
||||||
|
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
|
||||||
|
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
|
||||||
|
|
||||||
|
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
|
||||||
|
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
|
||||||
|
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
|
||||||
|
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
|
||||||
|
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
|
||||||
|
подстановку из боевой сборки тегом сборки:**
|
||||||
|
|
||||||
|
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
|
||||||
|
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
|
||||||
|
|
||||||
|
## Почему это ADR
|
||||||
|
|
||||||
|
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
|
||||||
|
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
|
||||||
|
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
|
||||||
|
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
|
||||||
|
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
|
||||||
|
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
|
||||||
|
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
|
||||||
|
«почему подстановка вообще есть в боевом бинарнике».
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
|
||||||
|
открывают по адресу сервиса, второго порта нет.
|
||||||
|
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
|
||||||
|
пришедшего от прокси к моменту, когда его читают.
|
||||||
|
- `+` Второго способа входить локально не остаётся, и документация перестаёт
|
||||||
|
каждый раз говорить, какой из способов чей.
|
||||||
|
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
|
||||||
|
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
|
||||||
|
- `−` **Местный инструмент больше не воспроизводит поломки контура.** Цена
|
||||||
|
названа в источнике прямо: два значения `Remote-User`, заголовок с
|
||||||
|
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
|
||||||
|
воспроизводит только автотест, ставящий заголовок сам.
|
||||||
|
- `−` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
|
||||||
|
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||||
|
- `−` У ключа `[server] debug` закрытый перечень следствий, и держать его
|
||||||
|
придётся руками: новое поведение привязывается к ключу только отдельным
|
||||||
|
решением владельца и получает своё требование спеки
|
||||||
|
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
|
||||||
|
следствий обрастает ими молча.
|
||||||
|
- `−` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
|
||||||
|
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
|
||||||
|
выключена — но лишние записи копятся.
|
||||||
+30
-4
@@ -21,7 +21,10 @@
|
|||||||
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
|
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
|
||||||
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
||||||
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
||||||
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
|
- Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
|
||||||
|
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
|
||||||
|
разделе «Последствия» и только фактом, который решения не меняет, — например
|
||||||
|
действующим адресом того, что решение завело.
|
||||||
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
||||||
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
|
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
|
||||||
источником, а не абзацем в теле.
|
источником, а не абзацем в теле.
|
||||||
@@ -32,14 +35,37 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
|
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
|
||||||
|
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
|
||||||
|
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
|
||||||
|
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
|
||||||
|
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||||
|
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||||
|
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||||
|
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
|
||||||
|
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
|
||||||
|
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
|
||||||
|
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
|
||||||
|
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
|
||||||
|
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
|
||||||
|
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
|
||||||
|
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||||
|
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||||
|
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||||
|
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||||
|
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||||
|
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||||
|
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||||
|
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
|
||||||
|
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||||
|
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||||
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
|
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
|
||||||
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
|
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
|
||||||
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
||||||
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
||||||
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
|
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
|
||||||
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
|
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||||
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
|
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||||
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
|
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
|
||||||
|
|
||||||
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
|
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
|
||||||
|
|||||||
+316
-88
@@ -5,126 +5,307 @@
|
|||||||
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
||||||
|
|
||||||
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
||||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
[passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
|
||||||
этого ещё не решено — в разделе «Открытые вопросы».
|
этого ещё не решено — в разделе «Открытые вопросы».
|
||||||
|
|
||||||
Заведены три capability:
|
Заведённые capability нормируют **поведение сервиса** для его потребителей —
|
||||||
|
все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
|
||||||
|
у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
|
||||||
|
от 2026-08-13 его нормы живут в самих шагах, их проверках и
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md).
|
||||||
|
|
||||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
|
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||||
нормируют проверки, написанные задачей `http-handler-tests-never-green`
|
входов**: приём только от узнанного, имя отправителя не доходит ни до
|
||||||
2026-08-11;
|
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||||
|
наблюдатель видит единственный поднятый вход. Задачи
|
||||||
|
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||||
|
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||||||
|
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||||
|
2026-08-14, `storage-without-pocketbase` 2026-08-22;
|
||||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
повтором и молчание конвейера наружу: задачи
|
||||||
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
|
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
||||||
|
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||||
|
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
|
||||||
|
контекста посреди шага остаются
|
||||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
|
||||||
2026-08-12.
|
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
|
||||||
|
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
|
||||||
|
панель владельца исчезла и не заменена ничем;
|
||||||
|
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
||||||
|
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||||||
|
целиком и вложением, как из сохранённого строится структура реплик без
|
||||||
|
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
|
||||||
|
Задача `record-centric-model` 2026-08-14;
|
||||||
|
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
|
||||||
|
приложения**: пространство адресов `/app/` и единая форма отказа с
|
||||||
|
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
|
||||||
|
чтение — страница записей ключом, карточка без текста и текст названного вида.
|
||||||
|
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
||||||
|
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
||||||
|
2026-08-15;
|
||||||
|
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
|
||||||
|
сервис его отдаёт, каким адресом оно открывается, что делает обновление
|
||||||
|
страницы посреди него и что человек видит, открыв его. Здесь же правило
|
||||||
|
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||||||
|
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||||||
|
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||||
|
его дальше: узнавание по заголовку доверенного источника, заведение учётной
|
||||||
|
записи первым обращением и то, какие адреса остаются открытыми. Собственный
|
||||||
|
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
|
||||||
|
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
|
||||||
|
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||||
|
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||||
|
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
|
||||||
|
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
|
||||||
|
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]` — с
|
||||||
|
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
|
||||||
|
`config-test-headers-login` 2026-08-23; решения —
|
||||||
|
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
|
||||||
|
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||||
|
|
||||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
|
||||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
Задача, которая его трогает, дописывает спеку своей capability.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
|
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
|
||||||
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
|
захвата и порядок выборки нормирует
|
||||||
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
|
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||||||
очереди тоже не заводим — решено 2026-08-11,
|
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||||||
|
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||||||
кандидатов в [research/job-queue.md](research/job-queue.md).
|
кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
|
||||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
|
||||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
одно слово — таблица перестала быть коллекцией.
|
||||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
|
||||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
задача возвращается в работу, нормирует
|
||||||
`main.go`.
|
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||||
|
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||||
|
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
|
||||||
|
код или оснастка; в боевом бинарнике он появляется только отдельным решением
|
||||||
|
владельца и только под ключом, названным своим предметом: имитацию заголовков
|
||||||
|
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
|
||||||
|
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
|
||||||
|
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
|
||||||
|
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
|
||||||
|
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
|
||||||
|
**закрытый** перечень следствий, и перечень этот ведёт спека
|
||||||
|
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
|
||||||
|
только подстановку заголовков». Новое следствие вешается на ключ только новым
|
||||||
|
требованием той же спеки.
|
||||||
|
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
|
||||||
|
слой не знает внешнего никогда. `internal/service` знает только
|
||||||
|
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
|
||||||
|
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
||||||
|
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||||
|
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||||
|
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
|
||||||
|
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
|
||||||
|
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
|
||||||
|
и правило на это направление заведено впервые.
|
||||||
|
|
||||||
|
## Слои и модель домена
|
||||||
|
|
||||||
|
**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает
|
||||||
|
ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах
|
||||||
|
и реализацией в инфраструктуре.
|
||||||
|
|
||||||
|
| Слой | Дом | Что живёт | Чего не знает |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` |
|
||||||
|
| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций |
|
||||||
|
| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов |
|
||||||
|
| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов |
|
||||||
|
| Входы | `internal/controller` | HTTP и пул воркеров | друг друга |
|
||||||
|
| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — |
|
||||||
|
|
||||||
|
Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты
|
||||||
|
самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а
|
||||||
|
импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча.
|
||||||
|
|
||||||
|
**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь
|
||||||
|
вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:
|
||||||
|
|
||||||
|
- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение
|
||||||
|
(`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе;
|
||||||
|
- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и
|
||||||
|
журнал событий принадлежат записи и живут её идентификатором, а правит агрегат
|
||||||
|
держатель захвата;
|
||||||
|
- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`,
|
||||||
|
`entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей
|
||||||
|
идентичности не имеют;
|
||||||
|
- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей
|
||||||
|
рукой и чем кончилось;
|
||||||
|
- **репозиторий** — интерфейсы `internal/contract`, реализации под
|
||||||
|
`internal/adapter/repo`;
|
||||||
|
- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией
|
||||||
|
пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`);
|
||||||
|
- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит
|
||||||
|
согласованным, а не заполняется полями снаружи.
|
||||||
|
|
||||||
|
**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в
|
||||||
|
домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения
|
||||||
|
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
||||||
|
полями, принадлежит `internal/entity`.
|
||||||
|
|
||||||
|
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
|
||||||
|
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
|
||||||
|
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
|
||||||
|
пришедшего приходит ему интерфейсом `contract.UserRepository`.
|
||||||
|
|
||||||
## Компоненты
|
## Компоненты
|
||||||
|
|
||||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/intake, delivery -->
|
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
|
||||||
|
|
||||||
| Компонент | Где | Что делает |
|
| Компонент | Где | Что делает |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
|
||||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||||
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
|
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
|
|
||||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
|
||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
|
||||||
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
|
||||||
|
| Оснастка владельца | `cmd/devtools` | Возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
|
||||||
|
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
||||||
|
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
||||||
|
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
||||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три
|
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
|
||||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
|
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
|
||||||
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
|
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
|
||||||
а тот, кто её захватил.
|
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
|
||||||
|
настройкой.
|
||||||
|
|
||||||
## Внешние границы и форматы
|
## Внешние границы и форматы
|
||||||
|
|
||||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
|
||||||
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
|
|
||||||
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
|
||||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
`UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
|
||||||
|
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
|
||||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||||
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
|
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
|
||||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||||
|
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
|
||||||
|
нужен. База и файлы записей лежат под одним каталогом данных.
|
||||||
|
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
|
||||||
|
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
|
||||||
|
отбрасывает — наблюдение и чем оно проверено, в
|
||||||
|
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
|
||||||
|
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
|
||||||
|
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
|
||||||
|
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
|
||||||
|
берём сами.
|
||||||
|
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||||||
|
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||||||
|
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||||||
|
сетезависимый адрес набора проверок; все такие перечислены в
|
||||||
|
[CLAUDE.md](../CLAUDE.md), «Гейт».
|
||||||
|
|
||||||
## Эксплуатация
|
## Эксплуатация
|
||||||
|
|
||||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||||
обратный прокси, который публикует HTTP-порт наружу.
|
обратный прокси, который публикует HTTP-порт наружу.
|
||||||
|
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
|
||||||
|
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
|
||||||
|
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
|
||||||
|
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||||
|
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||||
|
сервис поднимает молча. Чем это обеспечено и как проверено —
|
||||||
|
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
|
||||||
|
свойством безопасно и обратное направление: прежний образ поднимается на
|
||||||
|
конфиге с ключами, которых он ещё не знает.
|
||||||
|
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
|
||||||
|
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
|
||||||
|
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
|
||||||
|
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
|
||||||
|
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
|
||||||
|
схемы нет и не планируется.
|
||||||
|
|
||||||
|
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
|
||||||
|
выкладки `record-centric-model` откат работал, после перестал, а с уходом
|
||||||
|
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
|
||||||
|
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
|
||||||
|
потому, что риск сегодня чем-то грозит.
|
||||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/conversion, recognition -->
|
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
|
||||||
|
|
||||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
|
||||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
|
||||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||||
|
|
||||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||||||
или по сообщению об ошибке. Владелец — по метрике
|
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
|
||||||
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||||
Отдельного оповещения нет.
|
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||||
три воркера опрашивают базу вхолостую с паузой из
|
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||||||
|
контейнера. Отдельного оповещения нет.
|
||||||
|
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||||
|
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
|
||||||
|
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
|
||||||
|
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||||
|
вхолостую с паузой из
|
||||||
[database.md](database.md), «Настройки с числовым значением».
|
[database.md](database.md), «Настройки с числовым значением».
|
||||||
|
|
||||||
## Единые точки проекта
|
## Единые точки проекта
|
||||||
|
|
||||||
| Что | Где |
|
| Что | Где |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
|
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||||||
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
|
||||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
|
| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
|
||||||
|
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
|
||||||
|
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
|
||||||
|
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
|
||||||
|
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||||||
|
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||||||
|
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
|
||||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
|
||||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
|
||||||
|
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
|
||||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||||
|
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||||
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||||
|
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||||
|
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||||
|
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||||
|
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||||
|
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` |
|
||||||
|
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
|
||||||
|
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
|
||||||
|
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go` — `peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
|
||||||
|
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||||||
|
|
||||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||||||
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
|
||||||
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
|
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
|
||||||
сам.
|
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
|
||||||
|
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
|
||||||
|
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
|
||||||
|
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
@@ -133,33 +314,73 @@
|
|||||||
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
||||||
`inv pl -- transcriber` из `pet-project-server`.
|
`inv pl -- transcriber` из `pet-project-server`.
|
||||||
|
|
||||||
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
|
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||||||
процесс работает под непривилегированным пользователем `transcriber`.
|
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||||||
|
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||||||
|
работает под непривилегированным пользователем `transcriber` — в образе он
|
||||||
|
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
|
||||||
|
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
|
||||||
|
разрешить его не может. Числа те же, что при заведении пользователя.
|
||||||
|
|
||||||
|
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
|
||||||
|
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
|
||||||
|
делать нечего.
|
||||||
|
|
||||||
|
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
|
||||||
|
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
|
||||||
|
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
|
||||||
|
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||||
|
четыре места однажды, сколько бы подкоманд в нём ни завелось.
|
||||||
|
|
||||||
|
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
|
||||||
|
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
|
||||||
|
DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при
|
||||||
|
живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и
|
||||||
|
сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в
|
||||||
|
рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.
|
||||||
|
|
||||||
|
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
|
||||||
|
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
|
||||||
|
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
|
||||||
|
замерялось и замеряться не будет — решение владельца от 2026-08-15.
|
||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
|
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
|
||||||
обрабатывает PocketBase, а не наш код
|
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
|
||||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
|
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
|
||||||
и как связываются пользователь Telegram и пользователь веба. Панель
|
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
|
||||||
администратора при этом Authelia не закрывает: у неё свой пароль
|
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
|
||||||
суперпользователя.
|
[access](../openspec/specs/access/spec.md), решение —
|
||||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||||
|
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||||
|
зависит возвращение убранного входа.
|
||||||
|
Второго периметра на порту сервиса при этом не осталось: панель администратора
|
||||||
|
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
|
||||||
|
больше нечего.
|
||||||
|
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||||||
|
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||||||
|
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||||||
|
Решено делать SPA,
|
||||||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||||||
сборкой Vite — 2026-08-11,
|
сборкой Vite — 2026-08-11,
|
||||||
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
||||||
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
|
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
|
||||||
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
|
входит в гейт и слоем в сборку образа; как именно он зовётся — решением
|
||||||
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
|
[ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15.
|
||||||
компонентов.
|
Не решено, брать ли готовый набор компонентов.
|
||||||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом.
|
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
|
||||||
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
|
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
|
||||||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||||
[security.md](security.md).
|
[security.md](security.md).
|
||||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
|
||||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
|
`deferred-general` по длине не выяснены. Расчётные
|
||||||
потолок проекта — шесть часов, и он взят с запасом, а не замером.
|
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||||
|
записи живёт в хранилище»; откуда взято число —
|
||||||
|
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||||
|
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
|
||||||
|
она осталась только этим числом.
|
||||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||||
обрыв начинает загрузку заново.
|
обрыв начинает загрузку заново.
|
||||||
@@ -174,24 +395,31 @@
|
|||||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
|
||||||
или нет, тоже не решено.
|
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
|
||||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
|
||||||
конвертер этот случай не проверялся.
|
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
|
||||||
|
на этом случае не проверялся.
|
||||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
|
||||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
|
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
|
||||||
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
|
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
||||||
это обходится, никто не мерил.
|
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
||||||
|
паузы, а не замер
|
||||||
|
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
|
||||||
|
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
|
||||||
|
Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22
|
||||||
|
([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди
|
||||||
|
пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
|
||||||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||||
выкладке сегодня нет.
|
выкладке сегодня нет.
|
||||||
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
||||||
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
||||||
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
|
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
|
||||||
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
||||||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
||||||
|
|||||||
+19
-29
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||||
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
||||||
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
|
перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
|
||||||
|
которыми машина читает код. Причина: файл на несколько сотен строк
|
||||||
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
||||||
проверят именование и не дойдут до формы решения.
|
проверят именование и не дойдут до формы решения.
|
||||||
|
|
||||||
@@ -17,12 +18,14 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
|||||||
|
|
||||||
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||||
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
||||||
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
|
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
||||||
шаге и дублируется воркером.
|
предложение с заглавной буквы вместо константной категории.
|
||||||
|
|
||||||
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа
|
Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
|
||||||
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом
|
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
||||||
месте больше не долг, а регрессия.
|
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
||||||
|
держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
|
||||||
|
`config-example-toml`. Эти места больше не долг, а регрессия.
|
||||||
|
|
||||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||||
@@ -40,33 +43,20 @@ htmx, а здесь решено делать SPA — и перенесённы
|
|||||||
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
||||||
типизированной.
|
типизированной.
|
||||||
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
||||||
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
|
`0600`, самодокументируемый `config.example.toml`, проверка на старте.
|
||||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||||
ULID, разбор на входной границе, естественные ключи у деталей.
|
ULID, разбор на входной границе, естественные ключи у деталей.
|
||||||
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
||||||
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
||||||
над `fetch`, показ ошибок и состояний списка.
|
над `fetch`, показ ошибок и состояний списка.
|
||||||
|
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
|
||||||
|
(pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
|
||||||
|
правила. Про инструменты, а не про то, как писать тесты.
|
||||||
|
|
||||||
## Механизировано
|
## Что из этого проверяет машина
|
||||||
|
|
||||||
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
Перечень правил, доведённых до проверки, и место настройки каждого — в записи
|
||||||
промптах ревью не пересказывается.
|
[go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
|
||||||
|
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
|
||||||
| Правило | Где механизировано |
|
там же названы остатки правил — то, что правило не ловит. Числа механизированного
|
||||||
| --- | --- |
|
здесь нет намеренно: оно протухает при каждом новом правиле.
|
||||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
|
||||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
|
|
||||||
| Форматирование исходников | `.golangci.yml` → `gofmt` |
|
|
||||||
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
|
||||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
|
||||||
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
|
|
||||||
|
|
||||||
Не названное здесь место механизации означает, что проход по конвенциям будет
|
|
||||||
добросовестно проверять уже проверенное.
|
|
||||||
|
|
||||||
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
|
|
||||||
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
|
|
||||||
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
|
|
||||||
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
|
|
||||||
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
|
|
||||||
оставшееся прозой, проверяет человек на каждом ревью заново.
|
|
||||||
|
|||||||
+103
-26
@@ -4,20 +4,23 @@
|
|||||||
Правила оформления кода (How), не спецификация поведения.
|
Правила оформления кода (How), не спецификация поведения.
|
||||||
|
|
||||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||||
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
|
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
|
||||||
проверки пустых ключей внутри адаптеров.
|
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
|
||||||
|
|
||||||
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
|
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||||
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
|
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||||
участвует.
|
приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
|
||||||
|
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
|
||||||
|
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
|
||||||
|
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
|
||||||
|
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
|
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
|
||||||
используем**: окружение наследуется дочерними процессами и видно через
|
используем**: окружение наследуется дочерними процессами и видно через
|
||||||
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||||
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
|
|
||||||
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||||
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
||||||
прикладном коде нет, только загрузчик `internal/config`.
|
прикладном коде нет, только загрузчик `internal/config`.
|
||||||
@@ -28,13 +31,13 @@
|
|||||||
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
||||||
процесса.
|
процесса.
|
||||||
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
||||||
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
|
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
|
||||||
`config.toml` не коммитится.
|
`config.toml` не коммитится.
|
||||||
|
|
||||||
## config.dist.toml — самодокументируемый образец
|
## config.example.toml — самодокументируемый образец
|
||||||
|
|
||||||
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
|
`config.example.toml` коммитим как единый справочник по конфигу: все секции и
|
||||||
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||||
|
|
||||||
- **зачем** поле — что оно меняет в поведении;
|
- **зачем** поле — что оно меняет в поведении;
|
||||||
- **допустимые значения** — перечисление или границы;
|
- **допустимые значения** — перечисление или границы;
|
||||||
@@ -45,20 +48,37 @@
|
|||||||
port = <N> # порт HTTP-сервера
|
port = <N> # порт HTTP-сервера
|
||||||
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
||||||
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
||||||
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||||
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
|
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
|
||||||
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
|
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
|
||||||
одним домом — таблица «Настройки с числовым значением» в
|
одним домом — таблица «Настройки с числовым значением» в
|
||||||
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
|
[../database.md](../database.md); `config.example.toml` — источник истины по
|
||||||
полей.
|
составу полей.
|
||||||
|
|
||||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||||
|
|
||||||
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
|
||||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
|
||||||
|
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
|
||||||
|
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
|
||||||
|
входом.
|
||||||
|
|
||||||
|
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
|
||||||
|
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
|
||||||
|
применённая порознь любая из них роняет старт либо оставляет сервис никого не
|
||||||
|
узнающим. Рабочей строкой в образце стоит боевое значение —
|
||||||
|
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
|
||||||
|
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||||
|
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
|
||||||
|
какое из них чьё.
|
||||||
|
|
||||||
|
*Расхождение:* петлевые адреса в рецепте названы **парой** — `127.0.0.1` и
|
||||||
|
`::1`, — а не одним значением, хотя правило секции требует от образца только
|
||||||
|
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
|
||||||
|
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
|
||||||
|
входа.
|
||||||
|
|
||||||
## Поля по дискриминатору `type`
|
## Поля по дискриминатору `type`
|
||||||
|
|
||||||
@@ -69,7 +89,7 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
|||||||
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
|
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
|
||||||
обязательных полей; поля других значений не требуются. Неизвестное значение —
|
обязательных полей; поля других значений не требуются. Неизвестное значение —
|
||||||
ошибка на старте с перечислением поддерживаемых.
|
ошибка на старте с перечислением поддерживаемых.
|
||||||
- **Образец — по `type`.** В `config.dist.toml`:
|
- **Образец — по `type`.** В `config.example.toml`:
|
||||||
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
|
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
|
||||||
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
|
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
|
||||||
полей (зачем, границы, единицы — как у обычных полей);
|
полей (зачем, границы, единицы — как у обычных полей);
|
||||||
@@ -85,17 +105,39 @@ Ansible из `pet-project-server`). Приложение просто читае
|
|||||||
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
||||||
репозиторий и не окружение.
|
репозиторий и не окружение.
|
||||||
|
|
||||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||||
`yandex.object_storage_access_key_id`,
|
`yandex.object_storage_access_key_id`,
|
||||||
`yandex.object_storage_secret_access_key`.
|
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
|
||||||
|
2026-08-22 вместе с собственным входом.
|
||||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||||
владелец — пользователь процесса (`1000:1000`).
|
владелец — пользователь процесса (`1000:1000`).
|
||||||
- В `config.dist.toml` секретные поля — пустые строки.
|
- В `config.example.toml` секретные поля — пустые строки.
|
||||||
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
||||||
строки, и загрузчик их не отличает от настоящего значения.
|
строки, и загрузчик их не отличает от настоящего значения.
|
||||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
||||||
отрендеренный файл) — см. «Проверка и остановка на старте».
|
отрендеренный файл) — см. «Проверка и остановка на старте».
|
||||||
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||||
|
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
|
||||||
|
собирает библиотека разбора, и собирает она его из разбираемого куска:
|
||||||
|
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
|
||||||
|
Оборванная кавычка в строке секретного ключа — типовая поломка криво
|
||||||
|
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
|
||||||
|
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
|
||||||
|
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
|
||||||
|
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
|
||||||
|
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
|
||||||
|
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
|
||||||
|
платить не за что.
|
||||||
|
|
||||||
|
**Файл, способный нести секрет, называется в `.gitignore` и в
|
||||||
|
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
|
||||||
|
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
|
||||||
|
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
|
||||||
|
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
|
||||||
|
бы проверкой над проверкой, а такие проект не заводит
|
||||||
|
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
|
||||||
|
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
|
||||||
|
строку с одной стороны — вернуло её ревью.
|
||||||
|
|
||||||
## Проверка и остановка на старте
|
## Проверка и остановка на старте
|
||||||
|
|
||||||
@@ -110,15 +152,50 @@ Ansible из `pet-project-server`). Приложение просто читае
|
|||||||
- ключи внешних сервисов не пусты.
|
- ключи внешних сервисов не пусты.
|
||||||
|
|
||||||
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
||||||
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
|
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||||
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
|
выходит с кодом 1. Единого места проверки нет.
|
||||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
|
||||||
места проверки нет.
|
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||||
|
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||||
|
|
||||||
|
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
|
||||||
|
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
|
||||||
|
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
|
||||||
|
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
|
||||||
|
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
|
||||||
|
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
|
||||||
|
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
|
||||||
|
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
|
||||||
|
HTTP.
|
||||||
|
|
||||||
|
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
|
||||||
|
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
|
||||||
|
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
|
||||||
|
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
|
||||||
|
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
|
||||||
|
которой не управляют. Причина в цене умолчания: поднявшись с
|
||||||
|
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||||
|
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||||
|
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||||
|
силе для прочих секций, где секреты есть.
|
||||||
|
|
||||||
## Структура в коде
|
## Структура в коде
|
||||||
|
|
||||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||||
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
|
- Одна корневая структура `Config` с под-структурами по секциям. Перечень
|
||||||
`Database`, `Storage`, `Yandex`, `Telegram`).
|
секций и полей здесь не повторяем: источник истины по составу —
|
||||||
|
`config.example.toml`, действующие числа — [../database.md](../database.md),
|
||||||
|
«Настройки с числовым значением». Каталог данных задаётся одним ключом
|
||||||
|
`[storage] data_dir`
|
||||||
|
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
||||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||||
требует правки обоих мест.
|
требует правки обоих мест.
|
||||||
|
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
|
||||||
|
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
|
||||||
|
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
|
||||||
|
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||||
|
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||||
|
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||||
|
`config.example.toml` у поля стоит значение свежей установки. Первым таким
|
||||||
|
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
|
||||||
|
правила сейчас нет.
|
||||||
|
|||||||
@@ -2,29 +2,32 @@
|
|||||||
|
|
||||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||||
|
|
||||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
|
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
|
||||||
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
|
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
|
||||||
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
|
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
|
||||||
и разбора нет. Правила действуют на новый код; переписывание существующего —
|
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
|
||||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
записи не осталось.
|
||||||
|
|
||||||
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
|
**Механизировано:** сверка изменённого шага схемы с
|
||||||
в transcriber нет.
|
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
||||||
|
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
|
||||||
|
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Первичные ключи — ULID, не автоинкремент
|
## Первичные ключи — ULID, не автоинкремент
|
||||||
|
|
||||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||||
**приложением** в момент создания записи.
|
**приложением** в момент создания записи. Выдача монотонна внутри одной
|
||||||
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
|
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
|
||||||
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
|
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
|
||||||
хронологией не является: порядок берут по колонке времени с ключом.
|
времени мало.
|
||||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||||
записи сущности в логах.
|
записи сущности в логах.
|
||||||
- **Точка генерации и разбора одна**: создание — при вставке записи в
|
- **Точка генерации и разбора одна** — `internal/ident`: `New` выдаёт, `Parse`
|
||||||
репозитории, разбор — на входных границах. Самодельных генераторов по месту
|
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
|
||||||
вызова не заводим.
|
заводим.
|
||||||
|
|
||||||
## Канонический вид — lowercase
|
## Канонический вид — lowercase
|
||||||
|
|
||||||
@@ -44,23 +47,28 @@
|
|||||||
|
|
||||||
## Прочее
|
## Прочее
|
||||||
|
|
||||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||||
значения держит код.
|
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
|
||||||
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
|
панель владельца, и она вправе была завести значение, которого сервис не
|
||||||
кодом — ради панели владельца: правка руками не должна заводить состояние,
|
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
|
||||||
которого конвейер не знает. Цена названа: шестое состояние потребует нового
|
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
|
||||||
шага схемы.
|
покупателя.
|
||||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||||
вставка падает громко. Измерение длительности — не метка времени.
|
вставка падает громко. Измерение длительности — не метка времени.
|
||||||
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
|
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
|
||||||
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
|
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
|
||||||
[../database.md](../database.md) тем же изменением.
|
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
|
||||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
срока захвата в константу.
|
||||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
- Миграции — шаги `pressly/goose/v3` на Go
|
||||||
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
|
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
|
||||||
|
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
|
||||||
|
структуры обновляем схему [../database.md](../database.md) тем же изменением.
|
||||||
|
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
|
||||||
|
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
|
||||||
|
постоянную истину или ложь — молча.
|
||||||
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
||||||
сравнение по неуникальному значению делает порядок обработки
|
сравнение по неуникальному значению делает порядок обработки
|
||||||
невоспроизводимым.
|
невоспроизводимым.
|
||||||
|
|||||||
+62
-24
@@ -9,9 +9,10 @@
|
|||||||
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
||||||
решают сами.
|
решают сами.
|
||||||
|
|
||||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
|
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
|
||||||
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
|
сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения —
|
||||||
пакетов ошибок в проекте и так нет.
|
тест-сканер `internal/archrules`. Перечень и адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Базовая идиома: stdlib
|
## Базовая идиома: stdlib
|
||||||
|
|
||||||
@@ -64,9 +65,14 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
||||||
где хватает sentinel.
|
где хватает sentinel.
|
||||||
|
|
||||||
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
|
Типизированные ошибки проекта несут данные все до одной:
|
||||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||||
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
|
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
|
||||||
|
|
||||||
|
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
|
||||||
|
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
|
||||||
|
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
|
||||||
|
как случай, а не как живой код.
|
||||||
|
|
||||||
## Граница и трансляция: приватный и публичный канал
|
## Граница и трансляция: приватный и публичный канал
|
||||||
|
|
||||||
@@ -76,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||||
[logging.md](logging.md).
|
[logging.md](logging.md).
|
||||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
|
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
|
||||||
API). Сюда отдаём:
|
отдаём:
|
||||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
||||||
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
||||||
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
||||||
@@ -92,29 +98,51 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
|
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
|
||||||
HTTP и веба:
|
HTTP и веба:
|
||||||
|
|
||||||
| Доменная ошибка | Статус | Сообщение |
|
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| задача не найдена | 404 | «задача не найдена» |
|
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
|
||||||
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
|
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||||||
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
|
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
|
||||||
| прочее | 500 | «внутренняя ошибка» |
|
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||||||
|
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||||||
|
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||||
|
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||||||
|
|
||||||
|
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
|
||||||
|
случаем: им был владелец панели, предъявивший собственный токен хранилища.
|
||||||
|
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
|
||||||
|
учётную запись заводит само.
|
||||||
|
|
||||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||||
|
|
||||||
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
|
**Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
|
||||||
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
|
хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
|
||||||
любую ошибку заведения задачи.
|
`400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
|
||||||
|
был бы единственным оставшимся путём. Норму держит спека `archive`.
|
||||||
|
|
||||||
|
Точка живёт в `internal/controller/http.mapDomainError` и названа в
|
||||||
|
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
|
||||||
|
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
|
||||||
|
`json-api-for-spa` 2026-08-15.
|
||||||
|
|
||||||
|
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||||||
|
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
|
||||||
|
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
|
||||||
|
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
|
||||||
|
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
|
||||||
|
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
|
||||||
|
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
|
||||||
|
неоткуда.
|
||||||
|
|
||||||
### Разовый ответ и сохранённая диагностика
|
### Разовый ответ и сохранённая диагностика
|
||||||
|
|
||||||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||||||
|
|
||||||
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
|
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||||
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
|
||||||
полная ошибка остаётся в логах по идентификатору задачи.
|
полная ошибка остаётся в логах по идентификатору задачи.
|
||||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
|
||||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||||
и полезен. Но:
|
и полезен. Но:
|
||||||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||||||
@@ -124,9 +152,9 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||||
числом рядом**: без этого непонятно, насколько сокращать.
|
числом рядом**: без этого непонятно, насколько сокращать.
|
||||||
|
|
||||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
|
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
|
||||||
правила соблюдена.
|
машинного текста — эту часть правила держит спека `archive`.
|
||||||
|
|
||||||
## panic
|
## panic
|
||||||
|
|
||||||
@@ -135,10 +163,20 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||||
это значения `error`.
|
это значения `error`.
|
||||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||||
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
|
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
|
||||||
границы **нет**: паника в шаге конвейера роняет процесс целиком.
|
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
|
||||||
|
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
|
||||||
|
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
|
||||||
|
роняет процесс целиком.
|
||||||
|
|
||||||
## Несколько ошибок
|
## Несколько ошибок
|
||||||
|
|
||||||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||||
|
|
||||||
|
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
|
||||||
|
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
|
||||||
|
возвращается на первом несовпадении. Заметило ревью задачи
|
||||||
|
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
|
||||||
|
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
|
||||||
|
впервые, чинит одну ошибку за прогон.
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Линтеры и механизированные проверки
|
||||||
|
|
||||||
|
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
|
||||||
|
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
|
||||||
|
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
|
||||||
|
здесь строкой — эта запись его принимает.
|
||||||
|
|
||||||
|
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
|
||||||
|
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
||||||
|
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
||||||
|
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
||||||
|
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||||
|
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
||||||
|
исходники.
|
||||||
|
|
||||||
|
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
|
||||||
|
будет своя запись, а два круга останутся общими.
|
||||||
|
|
||||||
|
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
|
||||||
|
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
|
||||||
|
Своё здесь — перечень правил и подавлений.
|
||||||
|
|
||||||
|
## Границы: где что живёт
|
||||||
|
|
||||||
|
Чтобы факт не жил в двух местах:
|
||||||
|
|
||||||
|
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
|
||||||
|
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
|
||||||
|
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
|
||||||
|
один дом, и он у памятки, потому что её читают прежде работы;
|
||||||
|
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
|
||||||
|
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
|
||||||
|
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
|
||||||
|
дважды;
|
||||||
|
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
|
||||||
|
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
|
||||||
|
спрашивать уже не нужно;
|
||||||
|
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
|
||||||
|
проверок туда не входят: инструментарий спеками не нормируется, и спека
|
||||||
|
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
|
||||||
|
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
|
||||||
|
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
|
||||||
|
тем же решением. Второй самодельный
|
||||||
|
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
|
||||||
|
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||||
|
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
|
||||||
|
не числится: проверок над проверками проект не заводит —
|
||||||
|
[CLAUDE.md](../../CLAUDE.md), «Запреты».
|
||||||
|
|
||||||
|
## Два круга: pre-commit и гейт
|
||||||
|
|
||||||
|
Проверки идут двумя кругами, и круг выбирается по цене прогона.
|
||||||
|
|
||||||
|
| | pre-commit (`lefthook.yml`) | гейт (`task gate`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
|
||||||
|
| На чём | на **затронутых файлах** | на всём дереве |
|
||||||
|
| Сколько идёт | около секунды | десятки секунд |
|
||||||
|
| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон |
|
||||||
|
|
||||||
|
Перечень работ pre-commit и то, что остаётся только гейту, — в
|
||||||
|
[CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не
|
||||||
|
подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки
|
||||||
|
документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы
|
||||||
|
минуту, и хук отключили бы через день.
|
||||||
|
|
||||||
|
Полный набор проверок в pre-commit не переносится сознательно; обратное решение
|
||||||
|
— «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
|
||||||
|
|
||||||
|
## Механизировано
|
||||||
|
|
||||||
|
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
||||||
|
промптах ревью не пересказывается.
|
||||||
|
|
||||||
|
### Ошибки и отказы
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||||
|
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||||
|
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
|
||||||
|
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||||
|
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||||
|
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
|
||||||
|
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml` → `noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
|
||||||
|
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` |
|
||||||
|
|
||||||
|
### Структура и границы
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||||
|
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||||
|
| Транспорты не знают адаптеров | `internal/archrules` → `TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
|
||||||
|
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||||
|
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
|
||||||
|
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||||
|
|
||||||
|
### Отмена и внешний собеседник
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml` → `noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
|
||||||
|
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml` → `contextcheck` |
|
||||||
|
| Тело ответа HTTP закрывается | `.golangci.yml` → `bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
|
||||||
|
|
||||||
|
### Время, вывод, конфигурация
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml` → `forbidigo`; единая точка — `internal/clock` |
|
||||||
|
| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml` → `forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](logging.md) |
|
||||||
|
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml` → `forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
|
||||||
|
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
|
||||||
|
|
||||||
|
### Код проверок и подавления
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||||
|
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||||
|
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||||
|
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||||
|
|
||||||
|
### Форма кода и файлов вне Go
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Форматирование исходников | `.golangci.yml` → `gofmt`; на pre-commit правится на месте |
|
||||||
|
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
||||||
|
| Опечатка в комментарии и в тексте ошибки | `.golangci.yml` → `misspell` |
|
||||||
|
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
|
||||||
|
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
|
||||||
|
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
|
||||||
|
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
|
||||||
|
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
|
||||||
|
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
|
||||||
|
|
||||||
|
### Хранилище, документы, секреты, зависимости
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||||
|
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
|
||||||
|
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||||
|
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||||
|
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
|
||||||
|
|
||||||
|
Не названное здесь место механизации означает, что проход по конвенциям будет
|
||||||
|
добросовестно проверять уже проверенное.
|
||||||
|
|
||||||
|
## Подавления: что и почему
|
||||||
|
|
||||||
|
Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с
|
||||||
|
причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или
|
||||||
|
`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом.
|
||||||
|
|
||||||
|
| Подавлено | Где | Почему |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||||
|
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||||
|
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||||
|
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||||
|
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
|
||||||
|
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
|
||||||
|
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
|
||||||
|
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
|
||||||
|
|
||||||
|
## Что остаётся прозой
|
||||||
|
|
||||||
|
**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой
|
||||||
|
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
|
||||||
|
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
|
||||||
|
имён полей, канонический вид идентификатора, естественные ключи у деталей.
|
||||||
|
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
|
||||||
|
снявшее с него эту работу, всегда выигрыш.
|
||||||
|
|
||||||
|
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
|
||||||
|
осталось человеку:
|
||||||
|
|
||||||
|
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
|
||||||
|
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
|
||||||
|
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
|
||||||
|
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
|
||||||
|
`context.Background()` там, где контекст был доводом, — но метод, у которого
|
||||||
|
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
|
||||||
|
остаётся человеку;
|
||||||
|
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
|
||||||
|
добавленного после неё файла статус `A`, и правка такого файла законна — он
|
||||||
|
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||||
|
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||||
|
(`task migrations BASE=<rev>`);
|
||||||
|
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
|
||||||
|
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
|
||||||
|
[../architecture.md](../architecture.md), «Слои и модель домена».
|
||||||
|
|
||||||
|
Отдельно названы **правила, чей подъём отклонён**:
|
||||||
|
|
||||||
|
- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные
|
||||||
|
поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`);
|
||||||
|
- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая
|
||||||
|
константа в нижнем регистре», но код называет сообщения предложениями с
|
||||||
|
заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше
|
||||||
|
ста вызовов, и она не заплачена;
|
||||||
|
- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше.
|
||||||
|
|
||||||
|
**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому
|
||||||
|
`time.Since(clock.Now())` компилируется и молча меряет длительность настенными
|
||||||
|
часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у
|
||||||
|
`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет.
|
||||||
|
|
||||||
|
## Как заводят новое правило
|
||||||
|
|
||||||
|
Порядок один и тот же, и последние два шага пропускать нельзя.
|
||||||
|
|
||||||
|
1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
|
||||||
|
что проще включить: настройка готового линтера, запрет по имени, тест-сканер
|
||||||
|
исходников или свой шаг набора проверок.
|
||||||
|
2. **Написать причину рядом.** Правило без причины снимают при первом же
|
||||||
|
неудобстве: тот, кто снимает, не знает, что оно ловило.
|
||||||
|
3. **Починить находки, а не подавить.** Подавление годится, когда правило
|
||||||
|
говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с
|
||||||
|
причиной. Подавление «пока некогда» — это отложенная работа, и её место в
|
||||||
|
каталоге задач, а не в конфиге.
|
||||||
|
4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило
|
||||||
|
написано, и убедиться, что проверка краснеет и называет место. Правило,
|
||||||
|
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
|
||||||
|
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
|
||||||
|
2026-08-13 про обходимый текстовый запрет).
|
||||||
|
|
||||||
|
**Мутация обязана собираться.** Правка, снявшая последнее употребление
|
||||||
|
импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и
|
||||||
|
мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что
|
||||||
|
красное — от проверки.
|
||||||
|
|
||||||
|
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
|
||||||
|
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
|
||||||
|
мутация, задевшая сразу два правила, покажет только первое: так молчали
|
||||||
|
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
|
||||||
|
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
|
||||||
|
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
|
||||||
|
заменило.
|
||||||
+62
-43
@@ -11,8 +11,13 @@ OpenSpec.
|
|||||||
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
||||||
ошибку выше, где её логируют снова.
|
ошибку выше, где её логируют снова.
|
||||||
|
|
||||||
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
|
**Механизировано:** форма вызова — `sloglint`: только пары
|
||||||
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
|
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
|
||||||
|
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println` —
|
||||||
|
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
|
||||||
|
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
|
||||||
|
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
@@ -23,22 +28,28 @@ OpenSpec.
|
|||||||
`jq` без регулярных выражений.
|
`jq` без регулярных выражений.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
|
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||||
```
|
```
|
||||||
|
|
||||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
*Расхождение:* текстовый обработчик ставит `cmd/transcriber` —
|
||||||
|
`slog.NewTextHandler(os.Stdout, …)`.
|
||||||
|
|
||||||
|
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
|
||||||
|
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
|
||||||
|
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
|
||||||
|
выглядел бы хуже, чем есть.
|
||||||
|
|
||||||
## Сообщение
|
## Сообщение
|
||||||
|
|
||||||
- `msg` — короткая константа в нижнем регистре: `job accepted`,
|
- `msg` — короткая константа в нижнем регистре: `record accepted`,
|
||||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||||
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
|
`log.Info("record accepted", "record_id", id, "source", "api")`.
|
||||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||||
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
|
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
|
||||||
жизненный цикл собирался одним отбором:
|
жизненный цикл собирался одним отбором:
|
||||||
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
|
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
|
||||||
сверх перехода — отдельная запись своей категории (`file converted`,
|
сверх перехода — отдельная запись своей категории (`file converted`,
|
||||||
`text delivered`), она запись перехода не подменяет.
|
`text delivered`), она запись перехода не подменяет.
|
||||||
|
|
||||||
@@ -55,7 +66,7 @@ OpenSpec.
|
|||||||
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
||||||
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
||||||
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
||||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
|
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
|
||||||
@@ -73,9 +84,14 @@ OpenSpec.
|
|||||||
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
|
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
|
||||||
завершаем процесс с ненулевым кодом.
|
завершаем процесс с ненулевым кодом.
|
||||||
|
|
||||||
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
|
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
|
||||||
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
|
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
|
||||||
|
|
||||||
|
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
|
||||||
|
идёт на `INFO` — уровень и его довод нормирует спека
|
||||||
|
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
|
||||||
|
журнале».
|
||||||
|
|
||||||
## Время
|
## Время
|
||||||
|
|
||||||
- Поле — `time` (ключ `slog` по умолчанию).
|
- Поле — `time` (ключ `slog` по умолчанию).
|
||||||
@@ -89,19 +105,22 @@ OpenSpec.
|
|||||||
|
|
||||||
- Доменные поля — плоский `snake_case`.
|
- Доменные поля — плоский `snake_case`.
|
||||||
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
||||||
`ext.*`.
|
`ext.*`, `webapp.*`.
|
||||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||||
|
|
||||||
| Когда добавляем | Поля |
|
| Когда добавляем | Поля |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
|
||||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` |
|
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
||||||
|
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||||
| на запись об ошибке | `error` |
|
| на запись об ошибке | `error` |
|
||||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||||
|
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
|
||||||
|
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
||||||
|
|
||||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||||
|
|
||||||
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
|
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
|
||||||
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
|
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
|
||||||
пересечён с этим лишь частично.
|
пересечён с этим лишь частично.
|
||||||
|
|
||||||
@@ -115,16 +134,16 @@ OpenSpec.
|
|||||||
чтобы ключ дописывался на каждую запись сам:
|
чтобы ключ дописывался на каждую запись сам:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
log := log.With("job_id", job.Id, "capability", "conversion")
|
log := log.With("record_id", record.Id, "capability", "conversion")
|
||||||
```
|
```
|
||||||
|
|
||||||
- Все записи одной задачи собираются одним отбором:
|
- Все записи одной задачи собираются одним отбором:
|
||||||
`jq 'select(.job_id=="…")' app.jsonl`.
|
`jq 'select(.record_id=="…")' app.jsonl`.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Ошибки Go логируем как атрибут, а не как текст сообщения:
|
Ошибки Go логируем как атрибут, а не как текст сообщения:
|
||||||
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
|
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
|
||||||
|
|
||||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||||
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
|
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
|
||||||
@@ -132,7 +151,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
|
|||||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
||||||
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
||||||
транспорты остаются тонкими. Границы в transcriber:
|
транспорты остаются тонкими. Границы в transcriber:
|
||||||
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
|
- приём записи (`CreateJobFromApi`);
|
||||||
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||||
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
||||||
- завершение и отказ задачи (`completeJob`, `failJob`).
|
- завершение и отказ задачи (`completeJob`, `failJob`).
|
||||||
@@ -158,16 +177,15 @@ log := log.With("job_id", job.Id, "capability", "conversion")
|
|||||||
|
|
||||||
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
||||||
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
||||||
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
|
записи.
|
||||||
мимо `slog` целиком.
|
|
||||||
|
|
||||||
## Внешние сервисы: логируем все вызовы
|
## Внешние сервисы: логируем все вызовы
|
||||||
|
|
||||||
**Каждый** вызов внешнего сервиса логируется. Поля:
|
**Каждый** вызов внешнего сервиса логируется. Поля:
|
||||||
|
|
||||||
- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`;
|
- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`;
|
||||||
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
|
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
|
||||||
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
|
`PutObject`, `convert`);
|
||||||
- `ext.status_code` — код ответа, если применим;
|
- `ext.status_code` — код ответа, если применим;
|
||||||
- `duration_ms` — длительность вызова;
|
- `duration_ms` — длительность вызова;
|
||||||
- `retry` — номер попытки, если повторы были.
|
- `retry` — номер попытки, если повторы были.
|
||||||
@@ -187,13 +205,12 @@ log := log.With("job_id", job.Id, "capability", "conversion")
|
|||||||
|
|
||||||
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
||||||
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
||||||
Object Storage, скачивание файла из Telegram и опрос операции не логируются
|
Object Storage и опрос операции не логируются никак.
|
||||||
никак.
|
|
||||||
|
|
||||||
## HTTP и проверка здоровья
|
## HTTP и проверка здоровья
|
||||||
|
|
||||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||||
`http.status_code`, `duration_ms`, `transport`.
|
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
|
||||||
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
||||||
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
||||||
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
||||||
@@ -202,19 +219,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||||
`INFO` они не пишутся.
|
`INFO` они не пишутся.
|
||||||
|
|
||||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
Расхождения здесь больше нет: слой журналирования запросов свой —
|
||||||
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
|
||||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
то есть при боевом `INFO` не пишутся вовсе.
|
||||||
|
|
||||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
|
||||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
|
||||||
по которому разбирают отказы, эта таблица не попадает.
|
|
||||||
|
|
||||||
## Безопасность: что не логируем
|
## Безопасность: что не логируем
|
||||||
|
|
||||||
Никаких секретов в полях и сообщениях. Под запретом:
|
Никаких секретов в полях и сообщениях. Под запретом:
|
||||||
|
|
||||||
- токен бота Telegram;
|
|
||||||
- ключ SpeechKit и заголовок `Authorization`;
|
- ключ SpeechKit и заголовок `Authorization`;
|
||||||
- пара ключей Object Storage;
|
- пара ключей Object Storage;
|
||||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||||
@@ -230,22 +245,26 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
- При сомнении не логируем значение, логируем факт его наличия
|
- При сомнении не логируем значение, логируем факт его наличия
|
||||||
(`"has_api_key", true`).
|
(`"has_api_key", true`).
|
||||||
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
||||||
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
|
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
|
||||||
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
|
живёт прямо в пути. Такую ошибку разворачивают в
|
||||||
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
||||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||||
|
|
||||||
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
|
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
|
||||||
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
|
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
|
||||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
|
||||||
строки лога.
|
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
|
||||||
|
неверной.
|
||||||
|
|
||||||
*Расхождение:* расширение берётся из имени отправителя дословно
|
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||||
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
|
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||||
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
|
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||||
описан в [../security.md](../security.md).
|
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
|
||||||
|
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||||
|
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||||
|
[../security.md](../security.md).
|
||||||
|
|
||||||
## Куда пишем и уровень
|
## Куда пишем и уровень
|
||||||
|
|
||||||
@@ -258,5 +277,5 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
|
|
||||||
## Анализ
|
## Анализ
|
||||||
|
|
||||||
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
|
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
|
||||||
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
|
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
|
||||||
|
|||||||
+60
-19
@@ -10,17 +10,19 @@
|
|||||||
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
|
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
|
||||||
целиком вместе со сменой решения на SPA 2026-08-10.
|
целиком вместе со сменой решения на SPA 2026-08-10.
|
||||||
|
|
||||||
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на
|
Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
|
||||||
пробном экране, а не из написанного кода: первым их применяет и проверяет
|
они были выведены из выбора и из замера на пробном экране, а не из написанного
|
||||||
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, —
|
кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
|
||||||
повод править эту запись, а не обходить её молча.
|
эту запись, а не обходить её молча.
|
||||||
|
|
||||||
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
||||||
наружу — [errors.md](errors.md).
|
наружу — [errors.md](errors.md).
|
||||||
|
|
||||||
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
|
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
|
||||||
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения
|
команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
|
||||||
пока нет.
|
держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
|
||||||
|
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
|
||||||
|
требованием к машине разработчика остаётся docker, а не установленный Node.
|
||||||
|
|
||||||
## Что решено про само приложение
|
## Что решено про само приложение
|
||||||
|
|
||||||
@@ -33,11 +35,13 @@
|
|||||||
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
|
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
|
||||||
выполнения нет.
|
выполнения нет.
|
||||||
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
|
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
|
||||||
цели [web-access](../../tasks/items/web-access.md). Без сети приложение
|
из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
|
||||||
показывает состояние, а не пустой экран.
|
пустой экран.
|
||||||
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель
|
- **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
|
||||||
[ready-notification](../../tasks/items/ready-notification.md).
|
в [architecture.md](../architecture.md), «Уведомления», делает его
|
||||||
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом.
|
[ntfy-delivery](../../tasks/items/ntfy-delivery.md).
|
||||||
|
- **Записи звука в приложении не делаем** — граница из
|
||||||
|
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
|
||||||
|
|
||||||
## Фреймворк и сборка
|
## Фреймворк и сборка
|
||||||
|
|
||||||
@@ -49,18 +53,43 @@
|
|||||||
делать то же самое.
|
делать то же самое.
|
||||||
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
|
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
|
||||||
Vite, руками их не задаём: от этого зависит обновление установленного
|
Vite, руками их не задаём: от этого зависит обновление установленного
|
||||||
приложения.
|
приложения. Сервис на это правило опирается, но проверить его не может — имён
|
||||||
|
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
|
||||||
|
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
|
||||||
|
отпечатка в имени, останется в хранилище браузера навсегда.
|
||||||
|
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
|
||||||
|
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
|
||||||
|
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
|
||||||
|
совпадать с собранным в образе.
|
||||||
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
|
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
|
||||||
роняет гейт наравне с `go build`.
|
роняет гейт наравне с `go build`.
|
||||||
|
|
||||||
## Маршруты
|
## Маршруты
|
||||||
|
|
||||||
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по
|
- **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
|
||||||
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета
|
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
|
||||||
в установке и на четырёх маршрутах не окупается.
|
установке и на нашем числе маршрутов не окупается
|
||||||
|
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
|
||||||
|
экранов и какие — не здесь: состав нормирует спека
|
||||||
|
[webapp](../../openspec/specs/webapp/spec.md).
|
||||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||||
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
|
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||||
пути внутри `/api/` в приложение не проваливаются никогда.
|
`404`; путь внутри корня в приложение не проваливается никогда. Корень
|
||||||
|
сегодня **один** — `/app/` у приложения, — плюс `/health` и `/metrics`
|
||||||
|
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
|
||||||
|
ушёл с собственным входом, два других — со встроенным хранилищем и его
|
||||||
|
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
|
||||||
|
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
|
||||||
|
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
|
||||||
|
смены хранилища не изменились ни одним полем. Перечень корней сервису не
|
||||||
|
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
|
||||||
|
он разошёлся бы с ними молча.
|
||||||
|
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
||||||
|
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
||||||
|
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
||||||
|
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
|
||||||
|
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
|
||||||
|
ответов сервиса не останется ничего.
|
||||||
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
|
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
|
||||||
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
|
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
|
||||||
посередине.
|
посередине.
|
||||||
@@ -78,12 +107,17 @@
|
|||||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||||
не `Response`.
|
не `Response`.
|
||||||
|
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
|
||||||
|
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
|
||||||
|
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
|
||||||
|
[access](../../openspec/specs/access/spec.md), решение —
|
||||||
|
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||||
|
|
||||||
## Показ ошибок и состояний
|
## Показ ошибок и состояний
|
||||||
|
|
||||||
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
|
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
|
||||||
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
|
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
|
||||||
([json-api-for-spa](../../tasks/items/json-api-for-spa.md)), и второй словарь
|
(спека [archive](../../openspec/specs/archive/spec.md)), и второй словарь
|
||||||
на клиенте разошёлся бы с первым.
|
на клиенте разошёлся бы с первым.
|
||||||
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
|
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
|
||||||
строкой «связи нет», а не пустым экраном и не сообщением браузера.
|
строкой «связи нет», а не пустым экраном и не сообщением браузера.
|
||||||
@@ -94,11 +128,18 @@
|
|||||||
|
|
||||||
## Что не решено
|
## Что не решено
|
||||||
|
|
||||||
|
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
|
||||||
|
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
|
||||||
|
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
|
||||||
|
ходу.
|
||||||
|
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
|
||||||
|
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
|
||||||
|
Проверено прогоном 2026-08-15.
|
||||||
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
|
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
|
||||||
готовый способен удвоить собранный файл
|
готовый способен удвоить собранный файл
|
||||||
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
|
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
|
||||||
узнала»).
|
узнала»).
|
||||||
- **Устройство service worker и версионирование статики** — задача
|
- **Устройство service worker и версионирование статики** — задача
|
||||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||||
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос
|
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
|
||||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||||
|
|||||||
+411
-108
@@ -1,149 +1,452 @@
|
|||||||
# Схема хранилища
|
# Схема хранилища
|
||||||
|
|
||||||
Хранилище, коллекции, правило времени и идентификаторов.
|
База, таблицы, раскладка файлов, правило времени и идентификаторов.
|
||||||
|
|
||||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
|
||||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один —
|
||||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
|
||||||
CGO сборке не нужен.
|
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
|
||||||
|
задача `storage-without-pocketbase`,
|
||||||
|
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||||
|
|
||||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
|
||||||
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
|
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
|
||||||
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
|
правила, получают отказ по занятости на записи результата шага, то есть после
|
||||||
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
|
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
|
||||||
переписывается — изменение только новым шагом: применённое хранилище считает по
|
читатели не мешают писателю.
|
||||||
имени файла.
|
|
||||||
|
|
||||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
|
||||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
|
||||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
|
||||||
|
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
|
||||||
|
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
|
||||||
|
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
|
||||||
|
|
||||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
|
||||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
|
||||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
|
||||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
|
||||||
постоянную ложь молча.
|
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
|
||||||
|
Применённый шаг не переписывается — изменение только новым шагом.
|
||||||
|
|
||||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
||||||
повторяем: перечень единых точек и их отсутствий держит
|
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
|
||||||
[architecture.md](architecture.md), «Единые точки проекта».
|
чтения каталога; две одинаковых версии дают отказ сбора.
|
||||||
|
|
||||||
## Коллекции
|
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
|
||||||
|
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
|
||||||
|
запирателя накатывает без всякой блокировки. Замок берётся на файле
|
||||||
|
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
|
||||||
|
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
|
||||||
|
который надо чистить руками, не остаётся.
|
||||||
|
|
||||||
|
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||||
|
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||||
|
пути**, а префикс наводится только на каталог. Где этот префикс задан —
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
|
||||||
|
|
||||||
|
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
|
||||||
|
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
|
||||||
|
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
|
||||||
|
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
|
||||||
|
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
|
||||||
|
считается несуществующей записью и до базы не доходит.
|
||||||
|
|
||||||
|
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
|
||||||
|
файла внутри него — `<ULID><расширение>`.
|
||||||
|
|
||||||
|
**Время** — `TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
|
||||||
|
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
|
||||||
|
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
|
||||||
|
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
|
||||||
|
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
|
||||||
|
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
|
||||||
|
|
||||||
|
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
|
||||||
|
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
|
||||||
|
вставка, забывшая проставить время, при нём прошла бы молча.
|
||||||
|
|
||||||
|
## Таблицы
|
||||||
|
|
||||||
|
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
|
||||||
|
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
|
||||||
|
владельца правила запись руками и вправе была завести значение, которого сервис
|
||||||
|
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
|
||||||
|
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID, выдаёт приложение |
|
||||||
|
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
|
||||||
|
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
|
||||||
|
| `email` | TEXT | Адрес почты; необязателен |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
|
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
|
||||||
|
записи без почты уживаются друг с другом. Уникальность логина — обычным.
|
||||||
|
|
||||||
|
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
|
||||||
|
досталось бы чужой записи.
|
||||||
|
|
||||||
### `files`
|
### `files`
|
||||||
|
|
||||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||||
Object Storage — три разные записи.
|
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||||
|
считается — она существует только потому, что провайдер распознавания читает
|
||||||
|
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | ULID |
|
||||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
|
||||||
| `location` | select | `local` или `s3` |
|
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
|
||||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
|
||||||
| `size` | INTEGER | Размер в байтах |
|
| `size_bytes` | INTEGER | Размер копии в байтах |
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||||
|
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||||
|
| `created_at` | TEXT | Время |
|
||||||
|
|
||||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
|
||||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
|
||||||
|
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
|
||||||
|
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||||
|
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
|
||||||
|
|
||||||
### `transcribe_jobs`
|
### `audio_records`
|
||||||
|
|
||||||
Задача расшифровки и она же очередь.
|
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
|
||||||
|
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | ULID |
|
||||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
|
||||||
| `source` | select | `api`, `telegram`, `unknown` |
|
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
|
||||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
|
||||||
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
|
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
|
||||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
|
||||||
| `transcription_text` | editor | Результат распознавания |
|
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
|
||||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
| `delay_time` | TEXT | Не брать запись раньше этого времени |
|
||||||
|
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
||||||
|
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
|
||||||
|
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
|
||||||
|
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
|
||||||
|
| `structure_id` | TEXT | Структура реплик |
|
||||||
|
| `recognition_id` | TEXT | Попытка распознавания |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
|
Индексов два. `idx_audio_records_acquire` — `(state, halted_at, created_at, id)`:
|
||||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
|
||||||
этот один.
|
`idx_audio_records_owner_page` — `(owner_id, created_at, id)`: под страницу
|
||||||
|
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
|
||||||
|
|
||||||
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
|
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
|
||||||
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
|
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
|
||||||
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
|
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
|
||||||
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
|
сканирования таблицы.
|
||||||
|
|
||||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
||||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
||||||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
страница сканировала таблицу целиком и досортировывала результат во временном
|
||||||
`/api/settings` и `/api/crons` — `401`.
|
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
|
||||||
|
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
|
||||||
|
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
|
||||||
|
хранит их бессрочно.
|
||||||
|
|
||||||
|
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
|
||||||
|
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
|
||||||
|
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||||
|
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||||
|
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||||
|
файла на диске и в журнал оно по-прежнему не идёт.
|
||||||
|
|
||||||
|
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||||
|
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||||
|
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
|
||||||
|
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
|
||||||
|
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
|
||||||
|
а список читается без содержимого. Решение владельца от 2026-08-15.
|
||||||
|
|
||||||
|
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
|
||||||
|
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
|
||||||
|
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
|
||||||
|
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||||
|
никто не читает.
|
||||||
|
|
||||||
|
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||||
|
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||||
|
хранилище, и принятого человеком файла не найти было ничем.
|
||||||
|
|
||||||
|
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||||
|
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||||
|
признака, — и различие между ними перестало быть структурным.
|
||||||
|
|
||||||
|
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
||||||
|
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
||||||
|
справлялось ни с одной.
|
||||||
|
|
||||||
|
### `record_topics`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Запись |
|
||||||
|
| `topic_id` | TEXT → `topics(id)` | Тема |
|
||||||
|
|
||||||
|
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
|
||||||
|
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
|
||||||
|
Число берётся у домена — то же самое, которое сервис объявляет приложению.
|
||||||
|
|
||||||
|
### `texts`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
|
||||||
|
| `kind` | TEXT | `transcript` или `literary` |
|
||||||
|
| `contents` | TEXT | Сам текст |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
|
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
||||||
|
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
||||||
|
файла.
|
||||||
|
|
||||||
|
### `structures`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
|
||||||
|
| `version` | INTEGER | Версия вида разбора |
|
||||||
|
| `contents` | TEXT | Реплики со временем, JSON |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
|
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||||
|
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||||
|
|
||||||
|
### `recognitions`
|
||||||
|
|
||||||
|
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
|
||||||
|
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
||||||
|
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
||||||
|
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
||||||
|
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
|
||||||
|
| `started_at`, `finished_at` | TEXT | Границы операции |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
|
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
|
||||||
|
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
|
||||||
|
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
|
||||||
|
каждом опросе. Хранится он потому, что результат операции у провайдера не
|
||||||
|
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
|
||||||
|
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
|
||||||
|
|
||||||
|
### `record_events`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
|
||||||
|
| `origin` | TEXT | `pipeline` или `human` |
|
||||||
|
| `step` | TEXT | Имя шага |
|
||||||
|
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
|
||||||
|
| `outcome_text` | TEXT | Причина, если она есть |
|
||||||
|
| `duration_ms` | INTEGER | Сколько шаг занял |
|
||||||
|
| `created_at` | TEXT | Время |
|
||||||
|
|
||||||
|
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
||||||
|
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
||||||
|
двусмысленным.
|
||||||
|
|
||||||
|
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
|
||||||
|
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
||||||
|
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
|
||||||
|
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
|
||||||
|
журнала не осталось.
|
||||||
|
|
||||||
|
### `topics`
|
||||||
|
|
||||||
|
| Поле | Тип | Что |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | ULID |
|
||||||
|
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
|
||||||
|
| `name` | TEXT | Название темы |
|
||||||
|
| `created_at`, `updated_at` | TEXT | Время |
|
||||||
|
|
||||||
|
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
||||||
|
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
|
||||||
|
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
|
||||||
|
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
|
||||||
|
считающая темы, не платила вторым необратимым шагом схемы.
|
||||||
|
|
||||||
|
### Чего в схеме больше нет
|
||||||
|
|
||||||
|
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
|
||||||
|
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
|
||||||
|
«применённая миграция не переписывается», решением владельца от 2026-08-22:
|
||||||
|
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
|
||||||
|
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
|
||||||
|
годятся вовсе. Снятие кончается этим шагом.
|
||||||
|
|
||||||
|
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
|
||||||
|
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
|
||||||
|
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
|
||||||
|
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
|
||||||
|
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
|
||||||
|
одним новым шагом схемы.
|
||||||
|
|
||||||
|
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
|
||||||
|
причине:** их держал применённый шаг, которого больше не существует.
|
||||||
|
|
||||||
|
**Учётная запись с записями не удаляется**, и держит это схема обязательной
|
||||||
|
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
|
||||||
|
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
|
||||||
|
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
|
||||||
|
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
|
||||||
|
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
|
||||||
|
тупик до задачи про удаление записи.
|
||||||
|
|
||||||
## Представление данных
|
## Представление данных
|
||||||
|
|
||||||
Чем физически лежит запись и что происходит при чтении и записи.
|
Чем физически лежит запись и что происходит при чтении и записи.
|
||||||
|
|
||||||
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
|
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||||
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
|
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||||
целиком при каждом чтении задачи и при каждом захвате.
|
колонки шаг читает отдельным чтением.
|
||||||
- **Аудио лежит в раскладке хранилища:**
|
- **Файлы записи лежат подкаталогом на запись:**
|
||||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
|
||||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
|
||||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
убирается целиком одним движением; плоский каталог, где копии различаются
|
||||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
|
||||||
каталог и бакет растут неограниченно.
|
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
|
||||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
|
||||||
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
|
растут неограниченно.
|
||||||
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
|
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
|
||||||
хранилище **в журнал не пишется** — оно последняя часть ссылки.
|
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
дочитан до конца без отказа. Строка о файле заводится **после** этого;
|
||||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
содержимое легло, а строка не сохранилась — уложенный файл убирается.
|
||||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
- **Файл отдаётся адресом приложения** —
|
||||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
|
||||||
невоспроизводим.
|
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
|
||||||
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
|
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
|
||||||
работы достался другому, завершается без записи и без ответа отправителю.
|
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
|
||||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
|
||||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
|
||||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
|
||||||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
|
||||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
|
||||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
|
||||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
|
||||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
|
||||||
|
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
|
||||||
|
протухания захвата и отсутствию признака остановки; срок протухания выбирается
|
||||||
|
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
|
||||||
|
подставить его не может.
|
||||||
|
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||||
|
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||||
|
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
|
||||||
|
запросе правки, поэтому между проверкой и записью не остаётся окна.
|
||||||
|
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
|
||||||
|
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
|
||||||
|
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
|
||||||
|
который компилируется молча и кладёт идентификатор файла в колонку текста.
|
||||||
|
Перечень мест, где правится колонка, и серьёзность правила — инвариант
|
||||||
|
«Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md),
|
||||||
|
«Инварианты»; сверку держат правила `internal/archrules`.
|
||||||
|
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||||
|
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||||
|
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||||
|
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||||
|
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
|
||||||
|
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
|
||||||
|
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
|
||||||
|
полный URL объекта.
|
||||||
|
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
|
||||||
|
Отменять там нечего: операции местные и короткие, а единственное ожидание —
|
||||||
|
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
|
||||||
|
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
|
||||||
|
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
|
||||||
|
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
|
||||||
|
распознавания.
|
||||||
|
|
||||||
## Настройки с числовым значением
|
## Настройки с числовым значением
|
||||||
|
|
||||||
| Настройка | Значение | Где | Откуда число |
|
| Настройка | Значение | Где | Откуда число |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||||
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
|
| Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же |
|
||||||
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
|
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
|
||||||
| Срок захвата, распознавание | 8 часов | там же | то же |
|
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
|
||||||
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
|
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||||
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
|
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
||||||
|
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
||||||
|
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
|
||||||
|
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
|
||||||
|
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||||
|
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||||
|
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||||
|
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||||
|
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||||
|
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||||
|
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
|
||||||
|
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||||
|
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||||
|
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||||
|
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||||
|
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||||
|
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
|
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
|
||||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
|
||||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
|
||||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||||
|
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
|
||||||
|
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
|
||||||
|
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
|
||||||
|
|
||||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
|
||||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
|
||||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
|
||||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
|
||||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
|
||||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||||
нагрузке объявлена вне модели угроз.
|
ограничивает. Как читается цепочка — спека
|
||||||
|
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
|
||||||
|
|
||||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
|
||||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
|
||||||
файлов и объектов нет вовсе. Таймаутов у
|
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
|
||||||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
|
||||||
|
потолок времени на вход у провайдера и таймаут обмена кода.
|
||||||
|
|
||||||
|
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||||
|
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||||
|
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
|
||||||
|
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
|
||||||
|
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
|
||||||
|
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
|
||||||
|
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||||
|
подпадает: она снимает захват сама.
|
||||||
|
|
||||||
|
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
|
||||||
|
запроса приёма, и назван дважды: объявленная длина судится заранее, а
|
||||||
|
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
|
||||||
|
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
|
||||||
|
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
|
||||||
|
целенаправленной нагрузке объявлена вне модели угроз.
|
||||||
|
|
||||||
|
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
|
||||||
|
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||||
|
|||||||
+56
-37
@@ -1,7 +1,7 @@
|
|||||||
# Паспорт проекта
|
# Паспорт проекта
|
||||||
|
|
||||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||||
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
|
устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
|
||||||
«зачем и для кого».
|
«зачем и для кого».
|
||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
@@ -21,22 +21,24 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
|
||||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
|
|
||||||
|
|
||||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||||
несколько часов через Telegram не проходит вовсе.
|
приложение: диктофонная запись на несколько часов через Telegram не проходит
|
||||||
|
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
|
||||||
|
учётной записью. Вместе с ним из потребителей ушёл пользователь
|
||||||
|
Telegram.
|
||||||
|
|
||||||
Цель достигнута, когда:
|
Цель достигнута, когда:
|
||||||
|
|
||||||
- запись любого распространённого формата принимается без предварительной
|
- запись любого распространённого формата принимается без предварительной
|
||||||
подготовки, включая дорожку из видео;
|
подготовки, включая дорожку из видео;
|
||||||
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при
|
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
|
||||||
достижении предела;
|
ошибкой при достижении предела (норма — `openspec/specs/storage`);
|
||||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||||
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
- текст доступен там же, где загружали. Человек узнаёт о его готовности, не
|
||||||
узнаёт о его готовности, не держа приложение открытым;
|
держа приложение открытым;
|
||||||
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
|
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
|
||||||
заголовку и темам;
|
заголовку и темам;
|
||||||
- владелец видит расход по каждому пользователю и понимает, во что обходится
|
- владелец видит расход по каждому пользователю и понимает, во что обходится
|
||||||
@@ -54,22 +56,35 @@
|
|||||||
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
|
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
|
||||||
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
|
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
|
||||||
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
|
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
|
||||||
Направление — цель [text-insights](../tasks/items/text-insights.md).
|
Считать уровни текста берётся задача
|
||||||
|
[llm-insights-adapter](../tasks/items/llm-insights-adapter.md).
|
||||||
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
||||||
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
||||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||||
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
|
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
|
||||||
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
|
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||||
входит своим паролем, потому что подпустить к ней внешнего провайдера
|
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||||
PocketBase не даёт.
|
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||||
|
сервис не решает никогда. Панель администратора со своим паролем владельца жила
|
||||||
|
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
|
||||||
|
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
|
||||||
|
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
|
||||||
|
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
|
||||||
|
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
|
||||||
|
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
|
||||||
|
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
|
||||||
|
выключенным умолчанием, а границу его держит спека
|
||||||
|
[access](../openspec/specs/access/spec.md).
|
||||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||||
обрабатываем.
|
обрабатываем.
|
||||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||||
файл. Своей записи и работы без сети не делаем — граница цели
|
файл. Своей записи и работы без сети не делаем.
|
||||||
[web-access](../tasks/items/web-access.md).
|
|
||||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||||
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
|
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||||
записям сервис не становится.
|
доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
|
||||||
|
есть владелец, и чужую по её идентификатору не отдают
|
||||||
|
([security.md](security.md), «Периметр»). Закрыла это задача
|
||||||
|
`record-ownership`.
|
||||||
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
||||||
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
||||||
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
||||||
@@ -77,7 +92,9 @@
|
|||||||
|
|
||||||
## Типовые сценарии
|
## Типовые сценарии
|
||||||
|
|
||||||
Первые два — основные, и сегодня не работает ни один: приложения нет.
|
Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
|
||||||
|
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
|
||||||
|
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
|
||||||
|
|
||||||
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
|
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
|
||||||
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
|
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
|
||||||
@@ -87,19 +104,17 @@
|
|||||||
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
||||||
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
||||||
расшифровку.
|
расшифровку.
|
||||||
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
|
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
|
||||||
отвечает «обрабатываю», через минуту приходит текст ответом на то же
|
токеном, получает идентификатор записи и читает её карточку
|
||||||
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
|
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
|
||||||
несколькими частями. Работает сегодня.
|
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
|
||||||
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
|
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
|
||||||
отличает их по MIME-типу и расширению. Работает сегодня.
|
Своего способа представиться у программы нет — его заводит `api-tokens`.
|
||||||
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
Записи при этом разграничены: видны только записи того, чьим именем пришли.
|
||||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||||
увидит `done` и текст. Работает сегодня, но без токена и без разграничения
|
получает признак остановки с причиной, и карточка записи отдаёт признак и
|
||||||
доступа.
|
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
|
||||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
|
||||||
вышло, и предложение повторить.
|
|
||||||
|
|
||||||
## Референсы
|
## Референсы
|
||||||
|
|
||||||
@@ -109,7 +124,11 @@
|
|||||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||||
перестанет устраивать по цене или по качеству русской речи.
|
перестанет устраивать по цене или по качеству русской речи.
|
||||||
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11
|
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
|
||||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные
|
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
|
||||||
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником
|
перевела её в стек; стеком — 2026-08-22, когда задача
|
||||||
их не становится: заводит и проверяет людей по-прежнему Authelia.
|
`storage-without-pocketbase`
|
||||||
|
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
|
||||||
|
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
|
||||||
|
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
|
||||||
|
[database.md](database.md).
|
||||||
|
|||||||
@@ -20,8 +20,17 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
|||||||
|
|
||||||
## Записи
|
## Записи
|
||||||
|
|
||||||
|
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
|
||||||
|
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
|
||||||
|
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
|
||||||
|
сказано.
|
||||||
|
|
||||||
| Дата | Запись | О чём |
|
| Дата | Запись | О чём |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
|
||||||
|
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
|
||||||
|
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
|
||||||
|
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
|
||||||
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||||
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||||
|
|||||||
@@ -49,8 +49,9 @@
|
|||||||
|
|
||||||
## Захват чинится одним запросом
|
## Захват чинится одним запросом
|
||||||
|
|
||||||
Сегодняшний захват — два запроса подряд без транзакции
|
Захват **на момент замера** — два запроса подряд без транзакции; после перехода
|
||||||
([../database.md](../database.md), «Представление данных»). Замер показал, что
|
на PocketBase он свернулся в один с `RETURNING` —
|
||||||
|
[../database.md](../database.md), «Представление данных». Замер показал, что
|
||||||
после перехода на PocketBase он сворачивается в один: движок за
|
после перехода на PocketBase он сворачивается в один: движок за
|
||||||
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
|
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
|
||||||
горутинах разом запись получила **ровно одна**.
|
горутинах разом запись получила **ровно одна**.
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
# PocketBase: умолчания, которые ломают штатный сценарий
|
# PocketBase: умолчания, которые ломают штатный сценарий
|
||||||
|
|
||||||
|
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
|
||||||
|
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||||
|
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
|
||||||
|
Живое из записки переехало в [../database.md](../database.md), «Настройки с
|
||||||
|
числовым значением», — потолок размера одной записи, потолок тела запроса и
|
||||||
|
снятый таймаут чтения, — и в
|
||||||
|
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||||||
|
|
||||||
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
|
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
|
||||||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||||
|
|
||||||
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
|
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
|
||||||
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
|
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
|
||||||
|
значит обратное.
|
||||||
|
|
||||||
## Как снималось
|
## Как снималось
|
||||||
|
|
||||||
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
||||||
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
||||||
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
|
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
|
||||||
|
наблюдение — 2026-08-15.
|
||||||
|
|
||||||
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
||||||
|
|
||||||
@@ -86,6 +96,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
|||||||
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
||||||
владельца при следующем запуске её нет.
|
владельца при следующем запуске её нет.
|
||||||
|
|
||||||
|
## Обязательность связи проверяется у записи, а не у колонки
|
||||||
|
|
||||||
|
`Required` у поля связи — правило **проверки записи при сохранении**, а не
|
||||||
|
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
|
||||||
|
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
|
||||||
|
|
||||||
|
```
|
||||||
|
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
|
||||||
|
core/collection_validate.go ни одной проверки, читающей существующие строки
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
|
||||||
|
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
|
||||||
|
и вывод:
|
||||||
|
|
||||||
|
```
|
||||||
|
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
|
||||||
|
ПОСЛЕ ШАГА: строка на месте, owner=""
|
||||||
|
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
|
||||||
|
```
|
||||||
|
|
||||||
|
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
|
||||||
|
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
|
||||||
|
включая то, которым ставится признак остановки. Искать такие строки надо запросом
|
||||||
|
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
|
||||||
|
отличает. Цена решения записана в
|
||||||
|
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||||||
|
|
||||||
## Чего эта записка не узнала
|
## Чего эта записка не узнала
|
||||||
|
|
||||||
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
||||||
@@ -96,3 +134,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
|||||||
— нет.
|
— нет.
|
||||||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||||||
тестом на одной машине, не живой нагрузкой.
|
тестом на одной машине, не живой нагрузкой.
|
||||||
|
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
|
||||||
|
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
|
||||||
|
её цена на объёме.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# PocketBase: что даёт панель администратора
|
# PocketBase: что даёт панель администратора
|
||||||
|
|
||||||
|
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
|
||||||
|
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||||
|
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
|
||||||
|
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
|
||||||
|
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.
|
||||||
|
|
||||||
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
|
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
|
||||||
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
||||||
держать перевод хранилища в планах.
|
держать перевод хранилища в планах.
|
||||||
@@ -90,6 +96,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
|
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
|
||||||
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
|
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
|
||||||
|
|
||||||
|
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
|
||||||
|
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
|
||||||
|
гонок в гейте.
|
||||||
|
|
||||||
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
|
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
|
||||||
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
|
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
|
||||||
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
|
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
|
||||||
@@ -99,6 +109,91 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
|
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
|
||||||
на `/_/` кодом `200`.
|
на `/_/` кодом `200`.
|
||||||
|
|
||||||
|
## Вход через OIDC: что выяснилось при реализации
|
||||||
|
|
||||||
|
Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
|
||||||
|
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||||
|
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
|
||||||
|
было ни разу: провайдера подменял свой `httptest`-сервер.
|
||||||
|
|
||||||
|
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
|
||||||
|
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
|
||||||
|
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
|
||||||
|
Прогон подтвердил: `POST /api/collections/users/records` → `200`, следом
|
||||||
|
`auth-with-password` → `200` с токеном. То есть закрытие API за вход обходится
|
||||||
|
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
|
||||||
|
|
||||||
|
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
|
||||||
|
суперпользователь», а запись при первом входе заводит **внутренний** запрос
|
||||||
|
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
|
||||||
|
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
|
||||||
|
кончался `401`, учётных записей `0`. Работает правило
|
||||||
|
`@request.context = "oauth2"` — контекст ставит сам обмен
|
||||||
|
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
|
||||||
|
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
|
||||||
|
принимает поля создаваемой записи от вызывающего.
|
||||||
|
|
||||||
|
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
|
||||||
|
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
|
||||||
|
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
|
||||||
|
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
|
||||||
|
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
|
||||||
|
обслуживает всплывающее окно JS-клиента, а не серверный вход.
|
||||||
|
|
||||||
|
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
|
||||||
|
создавать заново при каждом вызове.**
|
||||||
|
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
|
||||||
|
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
|
||||||
|
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
|
||||||
|
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
|
||||||
|
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
|
||||||
|
перезапуск.
|
||||||
|
|
||||||
|
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
|
||||||
|
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
|
||||||
|
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
|
||||||
|
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
|
||||||
|
входу с этим адресом.
|
||||||
|
|
||||||
|
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
|
||||||
|
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
|
||||||
|
правило означает «только суперпользователь», поэтому одной пометки `Protected`
|
||||||
|
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
|
||||||
|
вошедшему с законно полученным токеном файла — пока правило не назначено.
|
||||||
|
|
||||||
|
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
|
||||||
|
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
|
||||||
|
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
|
||||||
|
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
|
||||||
|
слоем приложения поверх маршрута.
|
||||||
|
|
||||||
|
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
|
||||||
|
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
|
||||||
|
сессия переживает перезапуск сервиса сама, а смена ключа записи
|
||||||
|
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
|
||||||
|
|
||||||
|
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
|
||||||
|
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
|
||||||
|
|
||||||
|
**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
|
||||||
|
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
|
||||||
|
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
|
||||||
|
""` → `FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
|
||||||
|
ней стоит уникальный индекс
|
||||||
|
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
|
||||||
|
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
|
||||||
|
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
|
||||||
|
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
|
||||||
|
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
|
||||||
|
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
|
||||||
|
временного каталога — чтение исходников давало ту же цепочку, но противоположную
|
||||||
|
развязку.
|
||||||
|
|
||||||
|
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
|
||||||
|
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
|
||||||
|
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
|
||||||
|
суток; проект умолчание не переопределяет.
|
||||||
|
|
||||||
## Что отвергнуто и почему
|
## Что отвергнуто и почему
|
||||||
|
|
||||||
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
||||||
@@ -111,3 +206,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
способов:
|
способов:
|
||||||
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
|
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
|
||||||
копирование, которых у сервиса-архива нет никаких.
|
копирование, которых у сервиса-архива нет никаких.
|
||||||
|
|
||||||
|
## Разграничение по владельцу: что выяснилось при реализации
|
||||||
|
|
||||||
|
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
|
||||||
|
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||||
|
против настоящего хранилища на временном каталоге — в ходе ревью того change.
|
||||||
|
|
||||||
|
**Связь с выключенным каскадом не удерживает целостность при удалении.**
|
||||||
|
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
|
||||||
|
необязательном поле хранилище **вынимает** идентификатор из поля связи и
|
||||||
|
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
|
||||||
|
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
|
||||||
|
|
||||||
|
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
|
||||||
|
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
|
||||||
|
sure that the record is not part of a required relation reference.", err))`, а
|
||||||
|
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
|
||||||
|
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
|
||||||
|
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
|
||||||
|
|
||||||
|
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
|
||||||
|
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
|
||||||
|
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
|
||||||
|
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
|
||||||
|
|
||||||
|
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
|
||||||
|
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
|
||||||
|
из другой — например, владелец панели — узнан, но не годится, и код отказа это
|
||||||
|
различает.
|
||||||
|
|
||||||
|
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
|
||||||
|
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
|
||||||
|
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
|
||||||
|
не совпадает ни с кем, — но не буквально на уровне схемы.
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
|
|||||||
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
|
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
|
||||||
одинаковость экрана.
|
одинаковость экрана.
|
||||||
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
|
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
|
||||||
столько экранов у цели [web-access](../../tasks/items/web-access.md). Роутеры
|
столько экранов предполагала задача `spa-skeleton`, сделанная 2026-08-15.
|
||||||
|
Роутеры
|
||||||
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
|
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
|
||||||
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
|
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
|
||||||
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
|
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Хранилище: PocketBase против голого SQLite с каталогом файлов
|
||||||
|
|
||||||
|
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
|
||||||
|
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
|
||||||
|
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
|
||||||
|
постановки.
|
||||||
|
|
||||||
|
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
|
||||||
|
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
|
||||||
|
нет.
|
||||||
|
|
||||||
|
## Как снималось
|
||||||
|
|
||||||
|
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
|
||||||
|
репозитории, боевые данные не участвовали.
|
||||||
|
|
||||||
|
- **Пакеты в сборке:** `go list -deps ./... | wc -l` → **584**; из них с
|
||||||
|
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase` → **40**.
|
||||||
|
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
|
||||||
|
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
|
||||||
|
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
|
||||||
|
- **Узлы для варианта с монтированием** — чтением кэша модулей:
|
||||||
|
`router.Router.BuildMux()` отдаёт `http.Handler`
|
||||||
|
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
|
||||||
|
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
|
||||||
|
только их существование.
|
||||||
|
|
||||||
|
## PocketBase здесь — фреймворк приложения, а не хранилище
|
||||||
|
|
||||||
|
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
|
||||||
|
шесть ролей сразу, и только две из них про хранение.
|
||||||
|
|
||||||
|
| Роль | Где | Чем заменяется |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
|
||||||
|
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
|
||||||
|
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
|
||||||
|
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
|
||||||
|
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
|
||||||
|
| Панель `/_/` | покупалась ради неё | ничем |
|
||||||
|
|
||||||
|
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
|
||||||
|
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
|
||||||
|
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
|
||||||
|
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
|
||||||
|
|
||||||
|
## Три довода прежнего решения: один умер 2026-08-22
|
||||||
|
|
||||||
|
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||||
|
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
|
||||||
|
порознь ни одна перевода не оправдывает.
|
||||||
|
|
||||||
|
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
|
||||||
|
нет замены.
|
||||||
|
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
|
||||||
|
же ADR оговаривает, что копии сервер делает своими средствами.
|
||||||
|
3. **Вход через её провайдер OIDC** — **отпал**. Цитата, которой ADR отверг
|
||||||
|
вариант «взять библиотеку только хранилищем»:
|
||||||
|
|
||||||
|
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
|
||||||
|
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
|
||||||
|
> если вход делает само приложение**.
|
||||||
|
|
||||||
|
Вход делает само приложение с 2026-08-22
|
||||||
|
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
|
||||||
|
пришедшего называет заголовок прокси, а учётную запись заводит наш
|
||||||
|
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
|
||||||
|
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
|
||||||
|
перестал быть верным — и вместе с ним отпало основание держать вход в
|
||||||
|
библиотеке.
|
||||||
|
|
||||||
|
## Что оплачено и не работает
|
||||||
|
|
||||||
|
- **Захват записи идёт сырым запросом мимо записей коллекции**
|
||||||
|
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
|
||||||
|
На самом горячем месте абстракция не помогает, а её ограничения действуют —
|
||||||
|
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
|
||||||
|
сравниваются строками побайтово.
|
||||||
|
- **Налог соседства двух периметров в одном процессе и на одном порту.**
|
||||||
|
Появился секрет, которого не было, — пароль суперпользователя. Панель
|
||||||
|
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
|
||||||
|
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
|
||||||
|
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
|
||||||
|
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
|
||||||
|
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
|
||||||
|
абзацем в `identity.go`.
|
||||||
|
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
|
||||||
|
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
|
||||||
|
закрыть можно всё пространство хранилища разом.
|
||||||
|
|
||||||
|
## Чем платит уход
|
||||||
|
|
||||||
|
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
|
||||||
|
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
|
||||||
|
там.
|
||||||
|
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
|
||||||
|
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
|
||||||
|
единственным соединением, и библиотека держит это за нас двумя пулами.
|
||||||
|
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
|
||||||
|
На стройке цена нулевая: на сервере пусто, переносить нечего.
|
||||||
|
|
||||||
|
## Что уходит из сборки, а что остаётся
|
||||||
|
|
||||||
|
`go mod why -m` показал, что шесть модулей достижимы **только** через
|
||||||
|
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
|
||||||
|
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
|
||||||
|
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
|
||||||
|
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
|
||||||
|
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
|
||||||
|
|
||||||
|
## Варианты и что выбрано
|
||||||
|
|
||||||
|
| Вариант | Что делает | Чем платит |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
|
||||||
|
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
|
||||||
|
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
|
||||||
|
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
|
||||||
|
|
||||||
|
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
|
||||||
|
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
|
||||||
|
запросом к базе. Решение записано в
|
||||||
|
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||||
|
|
||||||
|
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
|
||||||
|
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
|
||||||
|
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Разбор TOML: какое семейство отказов несёт значения из файла
|
||||||
|
|
||||||
|
Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли
|
||||||
|
пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат
|
||||||
|
секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь
|
||||||
|
утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа —
|
||||||
|
то есть платой разборчивостью на каждой опечатке.
|
||||||
|
|
||||||
|
## Как снималось
|
||||||
|
|
||||||
|
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
|
||||||
|
`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`,
|
||||||
|
`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял
|
||||||
|
`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не
|
||||||
|
править код проекта.
|
||||||
|
|
||||||
|
## Что выяснилось
|
||||||
|
|
||||||
|
- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.**
|
||||||
|
Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q`
|
||||||
|
(`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid
|
||||||
|
integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf`
|
||||||
|
(`parse.go:134`).
|
||||||
|
- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e`
|
||||||
|
(`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через
|
||||||
|
`fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`.
|
||||||
|
- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после
|
||||||
|
`itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не
|
||||||
|
доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет.
|
||||||
|
- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и
|
||||||
|
`panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть
|
||||||
|
**длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`.
|
||||||
|
- **`ParseError` возвращается значением, не указателем** (`decode.go:564`,
|
||||||
|
`parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не
|
||||||
|
`*toml.ParseError`. `Unwrap` у типа нет.
|
||||||
|
- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка
|
||||||
|
секции даёт `LastKey=""`:
|
||||||
|
|
||||||
|
```
|
||||||
|
вход "[telegram\nenabled = true\n"
|
||||||
|
→ LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что из этого следует для кода
|
||||||
|
|
||||||
|
Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь,
|
||||||
|
строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы
|
||||||
|
проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а
|
||||||
|
несовпадение типов по-прежнему называет ключ и типы.
|
||||||
|
|
||||||
|
**Наблюдение привязано к версии.** Версия, переложившая значение в другое
|
||||||
|
семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это
|
||||||
|
проверки поломанного файла настроек в `internal/config/config_test.go`; при
|
||||||
|
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
|
||||||
|
не как случайный шум.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
|
||||||
|
|
||||||
|
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
|
||||||
|
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
|
||||||
|
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
|
||||||
|
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
|
||||||
|
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
|
||||||
|
самой библиотеки.
|
||||||
|
|
||||||
|
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
|
||||||
|
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
|
||||||
|
дающий.
|
||||||
|
|
||||||
|
## Как снималось
|
||||||
|
|
||||||
|
Прогонами на зависимости, зафиксированной в `go.mod`:
|
||||||
|
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
|
||||||
|
2026-08-23, отчёт —
|
||||||
|
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
|
||||||
|
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
|
||||||
|
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
|
||||||
|
|
||||||
|
- **Модульный.** Вход
|
||||||
|
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` —
|
||||||
|
опечатка в имени секции.
|
||||||
|
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
|
||||||
|
каталог данных вне репозитория; проба `curl /app/me`.
|
||||||
|
|
||||||
|
## Что выяснилось
|
||||||
|
|
||||||
|
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
|
||||||
|
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
|
||||||
|
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
|
||||||
|
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
|
||||||
|
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
|
||||||
|
путей, которых структура не знала: `[auth.test_headrs
|
||||||
|
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
|
||||||
|
загрузка настроек, ни проверки старта.
|
||||||
|
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
|
||||||
|
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
|
||||||
|
ловит проверка старта — `auth: секция [auth.test_headers] называет
|
||||||
|
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
|
||||||
|
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
|
||||||
|
доходит.
|
||||||
|
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
|
||||||
|
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
|
||||||
|
`INFO "Incoming request" … http.status_code=401`.
|
||||||
|
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
|
||||||
|
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
|
||||||
|
поднимается. Свойство держится ровно на тишине выше: перечень
|
||||||
|
`MetaData.Undecoded()` никто не судит.
|
||||||
|
|
||||||
|
## Что из этого следует для кода
|
||||||
|
|
||||||
|
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
|
||||||
|
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
|
||||||
|
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
|
||||||
|
надо ли его менять.
|
||||||
|
|
||||||
|
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
|
||||||
|
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
|
||||||
|
снимает безопасность отката, потому что прежний образ перестанет поднимать
|
||||||
|
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
|
||||||
|
а не попутная правка.
|
||||||
|
|
||||||
|
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
|
||||||
|
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Раздача приложения: что делают за нас библиотека и сборщик
|
||||||
|
|
||||||
|
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
|
||||||
|
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
|
||||||
|
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
|
||||||
|
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
|
||||||
|
|
||||||
|
## Как снималось
|
||||||
|
|
||||||
|
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
|
||||||
|
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
|
||||||
|
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
|
||||||
|
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
|
||||||
|
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
|
||||||
|
|
||||||
|
## Что выяснилось
|
||||||
|
|
||||||
|
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
|
||||||
|
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
|
||||||
|
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
|
||||||
|
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
|
||||||
|
— правилом обратного прокси, — такой формы не видит. Цена записана в
|
||||||
|
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
|
||||||
|
обходится подменой одного знака.
|
||||||
|
|
||||||
|
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
|
||||||
|
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
|
||||||
|
целиком
|
||||||
|
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
|
||||||
|
хранить пять суток, адрес записывать. Готовая раздача статики
|
||||||
|
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
|
||||||
|
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
|
||||||
|
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
|
||||||
|
|
||||||
|
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
|
||||||
|
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
|
||||||
|
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
|
||||||
|
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
|
||||||
|
делает.
|
||||||
|
|
||||||
|
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
|
||||||
|
положенная рядом с собранным ради того, чтобы каталог существовал в git,
|
||||||
|
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
|
||||||
|
|
||||||
|
- **Проверка типов однофайловых компонентов не работает с седьмой линией
|
||||||
|
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
|
||||||
|
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
|
||||||
|
пара — пятая линия TypeScript.
|
||||||
|
|
||||||
|
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
|
||||||
|
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
|
||||||
|
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
|
||||||
|
даёт зависание. Помогает короткое обращение-проба перед установкой.
|
||||||
|
|
||||||
|
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
|
||||||
|
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
|
||||||
|
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
|
||||||
|
экрана в разведке `spa-framework` весило того же порядка.
|
||||||
|
|
||||||
|
## Чего эта записка не узнала
|
||||||
|
|
||||||
|
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
|
||||||
|
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
|
||||||
|
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
|
||||||
|
финальный образ от неё не растёт вовсе.
|
||||||
|
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
|
||||||
|
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
|
||||||
|
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
|
||||||
|
и заголовками, а не браузером.
|
||||||
+681
-88
@@ -2,11 +2,47 @@
|
|||||||
|
|
||||||
## Как настроен конвейер
|
## Как настроен конвейер
|
||||||
|
|
||||||
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении
|
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
|
||||||
`fix-http-handler-tests`; его триаж лежит в
|
менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
|
||||||
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`.
|
`code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
|
||||||
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
|
||||||
ложноположительные» первым прогоном уже пользовались.
|
прогоном, и смотреть его надо в самом архиве.
|
||||||
|
|
||||||
|
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
|
||||||
|
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
|
||||||
|
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
|
||||||
|
прогонов.
|
||||||
|
|
||||||
|
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
|
||||||
|
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
|
||||||
|
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
|
||||||
|
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
|
||||||
|
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
|
||||||
|
правила, оба прозой и без механизации: **поднял — останови за собой**, а
|
||||||
|
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
|
||||||
|
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
|
||||||
|
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
|
||||||
|
|
||||||
|
**Ни один проход не сообщает свой потолок, и это надо читать как границу
|
||||||
|
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
|
||||||
|
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
|
||||||
|
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
|
||||||
|
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
|
||||||
|
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
|
||||||
|
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
|
||||||
|
остаётся сверка триажа.
|
||||||
|
|
||||||
|
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
|
||||||
|
не единичный случай: строки о потолке не дал ни один из шести проходов, а
|
||||||
|
заметил это снова только триаж. Состав прогона при этом был самым широким из
|
||||||
|
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
|
||||||
|
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
|
||||||
|
а не как свойство одного из них.
|
||||||
|
|
||||||
|
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||||
|
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||||||
|
узлах».
|
||||||
|
|
||||||
### Типовые узлы
|
### Типовые узлы
|
||||||
|
|
||||||
@@ -23,14 +59,34 @@
|
|||||||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||||||
- отвечает пользователю ровно один раз.
|
- отвечает пользователю ровно один раз.
|
||||||
|
|
||||||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
**Транспорт** (`internal/controller/http`):
|
||||||
|
|
||||||
- проверяет право отправителя до всякой работы;
|
- проверяет право отправителя до всякой работы;
|
||||||
- не логирует ошибку, которую уже залогировал доменный слой;
|
- не логирует ошибку, которую уже залогировал доменный слой;
|
||||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||||
- закрывает то, что открыл, на всех ветках выхода.
|
- закрывает то, что открыл, на всех ветках выхода.
|
||||||
|
|
||||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
|
||||||
|
шаг `front`):
|
||||||
|
|
||||||
|
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
|
||||||
|
корней порождает регистрацию маршрутов, а не описывает её;
|
||||||
|
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
|
||||||
|
кодом `200`;
|
||||||
|
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
|
||||||
|
сборщика: отозвать его у браузера сервису нечем;
|
||||||
|
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
|
||||||
|
нет» отличается от «файла нет»;
|
||||||
|
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||||||
|
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||||||
|
**отказывает, а не висит**;
|
||||||
|
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
|
||||||
|
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
|
||||||
|
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
|
||||||
|
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
|
||||||
|
пишется дословно ни под каким корнем, включая корень приложения.
|
||||||
|
|
||||||
|
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||||
|
|
||||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||||||
@@ -38,12 +94,20 @@
|
|||||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||||
успех молча.
|
успех молча.
|
||||||
|
|
||||||
**Репозиторий SQLite** (`adapter/repo/sqlite`):
|
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
|
||||||
|
подпакетом `migrations`):
|
||||||
|
|
||||||
- список колонок совпадает во всех четырёх запросах файла;
|
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
|
||||||
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
|
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
|
||||||
- захват задачи не выдаёт одну строку двум вызывающим;
|
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
|
||||||
- ошибка драйвера транслируется в доменную у источника.
|
**именами**: именованный параметр запроса и место назначения по имени, а не
|
||||||
|
позиция в списке;
|
||||||
|
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||||
|
держатель захвата, и держатель узнаётся значением признака;
|
||||||
|
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
|
||||||
|
из единой точки ([database.md](database.md), «Представление данных»);
|
||||||
|
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
|
||||||
|
целиком.
|
||||||
|
|
||||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||||
`adapter/metaviewer/ffmpeg`):
|
`adapter/metaviewer/ffmpeg`):
|
||||||
@@ -58,25 +122,26 @@
|
|||||||
|
|
||||||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||||||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||||||
становится неотличим от привычного шума (журнал, запись 2026-08-10);
|
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||||||
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
|
|
||||||
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
|
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
|
||||||
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
|
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
|
||||||
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
|
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
|
||||||
запись 2026-08-11);
|
|
||||||
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
|
|
||||||
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
|
|
||||||
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
|
|
||||||
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
|
|
||||||
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
|
|
||||||
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
|
|
||||||
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
|
|
||||||
- **требование без сценария не имеет оракула** и потому не может быть нарушено
|
|
||||||
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
|
|
||||||
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
|
|
||||||
|
|
||||||
### Типовые ложноположительные
|
### Типовые ложноположительные
|
||||||
|
|
||||||
|
- **«Хранилище молча сливает две учётные записи с одной почтой в одного
|
||||||
|
владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
|
||||||
|
половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
|
||||||
|
найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
|
||||||
|
признак он не может — уникальный индекс
|
||||||
|
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
|
||||||
|
отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
|
||||||
|
`Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
|
||||||
|
не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
|
||||||
|
Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
|
||||||
|
исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
|
||||||
|
без прогона, здесь и измерена.
|
||||||
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
||||||
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
||||||
логирует его и не считает в метрику. Норма записана требованием
|
логирует его и не считает в метрику. Норма записана требованием
|
||||||
@@ -87,28 +152,63 @@
|
|||||||
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
||||||
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
||||||
её как известную нельзя.
|
её как известную нельзя.
|
||||||
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
|
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
|
||||||
нет: три воркера читают три разных состояния, и одну строку они не делят.
|
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
|
||||||
Механика захвата и её слабые места — [database.md](database.md),
|
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
|
||||||
«Представление данных». Находка становится настоящей ровно тогда, когда
|
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
|
||||||
появится второй экземпляр процесса или второй воркер на то же состояние.
|
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
|
||||||
|
гонке захвата стала настоящей и выбрасывается только по существу — механика
|
||||||
|
захвата и её слабые места в [database.md](database.md), «Представление
|
||||||
|
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
|
||||||
|
редакцию, иначе выбросил бы настоящую находку как известную.
|
||||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||||
Новой находкой это не считается, пока не измерен рост.
|
Новой находкой это не считается, пока не измерен рост.
|
||||||
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
|
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
|
||||||
[security.md](security.md). Находкой считается только новая поверхность,
|
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
|
||||||
выставленная наружу, а не повторение этого факта.
|
иначе выбросил бы настоящую находку как известную.
|
||||||
|
|
||||||
|
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
|
||||||
|
дефект и не новость»: разграничения не было сознательно. Первая отмена
|
||||||
|
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
|
||||||
|
владельца, принятую ботом.
|
||||||
|
|
||||||
|
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
|
||||||
|
колонка владельца пустого значения больше не принимает, ничьих записей у
|
||||||
|
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
|
||||||
|
а не известное исключение.
|
||||||
|
|
||||||
### Вопросы по темам
|
### Вопросы по темам
|
||||||
|
|
||||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||||
|
|
||||||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
|
||||||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
|
||||||
`transcribe.go`, 2026-08-10).
|
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
|
||||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
|
||||||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
|
||||||
2026-08-10).
|
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
|
||||||
|
он: заголовки входа подставляет сам сервис под предохранителем
|
||||||
|
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
|
||||||
|
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
|
||||||
|
закрыт той же задачей:** норма о подставных собеседниках записана в
|
||||||
|
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
|
||||||
|
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
|
||||||
|
Вопрос при этом остаётся вопросом:
|
||||||
|
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
|
||||||
|
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||||||
|
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||||
|
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||||
|
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||||
|
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||||
|
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||||
|
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||||
|
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
|
||||||
|
дефектом «остановка хоронила запись»).
|
||||||
|
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
|
||||||
|
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||||
|
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
|
||||||
|
`speechkit.go`, 2026-08-13).
|
||||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||||
2026-08-10).
|
2026-08-10).
|
||||||
@@ -121,54 +221,100 @@
|
|||||||
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
||||||
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
||||||
журнала (журнал, запись 2026-08-11 про хвост имени).
|
журнала (журнал, запись 2026-08-11 про хвост имени).
|
||||||
- `architecture`: не появился ли второй путь приёма мимо
|
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
|
||||||
`createTranscribeJob` — сегодня через него идут оба входа
|
он единственный, которым запись попадает в хранилище
|
||||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||||
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`),
|
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
|
||||||
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами
|
под маркерами долга, а соблазн дописать туда ещё — самый большой.
|
||||||
долга, а соблазн дописать туда ещё — самый большой.
|
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
рубеж — одним дескриптором
|
||||||
(CLAUDE.md, «Инварианты»).
|
(CLAUDE.md, «Инварианты»).
|
||||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
|
||||||
тестов два файла, и оба мимо конвейера.
|
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
|
||||||
|
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
|
||||||
|
2026-08-15 про единую форму отказа).
|
||||||
|
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
|
||||||
|
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
|
||||||
|
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
|
||||||
|
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
|
||||||
|
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
|
||||||
|
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
|
||||||
|
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
|
||||||
|
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
|
||||||
|
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||||
|
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||||||
|
числа файлов здесь не называем — оно протухает с каждой задачей.
|
||||||
|
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||||||
|
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||||||
|
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||||||
|
2026-08-12 роняет гейт правилом линтера
|
||||||
|
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
|
||||||
|
спрашивать о нём не нужно.
|
||||||
|
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
|
||||||
|
собственная регистрация, вход по паролю, одноразовый код, восстановление
|
||||||
|
доступа, продление сессии. Всё это приходит включённым и закрывается нами
|
||||||
|
(задача `oidc-login` 2026-08-12).
|
||||||
|
- `security`: не появился ли второй способ получить сессию к тому же человеку —
|
||||||
|
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
|
||||||
|
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
|
||||||
|
после входа сервис к провайдеру не обращается, и канал здесь один
|
||||||
|
(ADR-2026-08-12-session-without-refresh).
|
||||||
|
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
|
||||||
|
приложения, — сборка роутера хранилища оказалась именно такой.
|
||||||
|
|
||||||
### Триггеры метки
|
### Когда звать глубокое ревью
|
||||||
|
|
||||||
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
|
Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
|
||||||
|
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
|
||||||
|
здесь только места этого проекта.
|
||||||
|
|
||||||
**Крупное здесь** (поднимает до `large`, ось объёма):
|
**Смотрим целиком** (область кода, а не дифф задачи):
|
||||||
|
|
||||||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
- **вход и разграничение доступа** — звенья `internal/controller/http`
|
||||||
колонку разом;
|
(узнавание, требование учётной записи, ограничитель частоты, подстановка
|
||||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
заголовков отладочного запуска) вместе со спекой
|
||||||
- смена модели очереди: захват, повторы и воркеры разом;
|
[access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
|
||||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
|
||||||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
|
||||||
|
ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
|
||||||
|
журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
|
||||||
|
2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
|
||||||
|
запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
|
||||||
|
прогоне нет (см. «Недоступно проверке»);
|
||||||
|
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
|
||||||
|
«Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
|
||||||
|
«Инварианты», major): места, цена забытого и то, чем держится сверка, названы
|
||||||
|
там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
|
||||||
|
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
|
||||||
|
`internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
|
||||||
|
дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
|
||||||
|
забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
|
||||||
|
второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
|
||||||
|
сервиса, хоронившая конвертируемую запись;
|
||||||
|
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
|
||||||
|
чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
|
||||||
|
деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
|
||||||
|
проверке»), и разбор остаётся единственным способом судить о нём.
|
||||||
|
|
||||||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
**Необратимое здесь.** Перечень необратимого один и лежит в
|
||||||
|
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
|
||||||
|
пункты касаются, и правило прохода: находка в таком месте уходит человеку
|
||||||
|
развилкой, а не чинится молча.
|
||||||
|
|
||||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
- применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
|
||||||
пользователь приложения, до начала работы назвать нельзя;
|
- формат файла на диске и раскладка каталога данных —
|
||||||
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается
|
`internal/adapter/repo/sqlite`, `store.go`;
|
||||||
по ходу, пока конвенция веб-UI пуста;
|
- публичный контракт HTTP API — `internal/controller/http`;
|
||||||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
- имя ключа конфига — `internal/config`;
|
||||||
до работы назвать нельзя;
|
- действие с боевыми данными и с Yandex Cloud, ротация секрета —
|
||||||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
`internal/adapter/recognizer/yandex`.
|
||||||
форма решения зависит от замера;
|
|
||||||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
|
||||||
- всё, что требует записи в `research/` прежде, чем начать.
|
|
||||||
|
|
||||||
**Мелкое здесь** (опускает до `small`):
|
Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
|
||||||
|
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
|
||||||
- правка текста, который видит пользователь Telegram;
|
([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
|
||||||
- новая метрика в `internal/metrics`;
|
там только следующей записи.
|
||||||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
|
||||||
- правка документов канона.
|
|
||||||
|
|
||||||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
|
||||||
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
|
|
||||||
ни были, они не `small`.
|
|
||||||
|
|
||||||
### Недоступно проверке
|
### Недоступно проверке
|
||||||
|
|
||||||
@@ -179,7 +325,16 @@ API и имя не откатываются обратной правкой по
|
|||||||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||||
день, и утверждения о росте остаются условиями, а не замерами;
|
день, и утверждения о росте остаются условиями, а не замерами;
|
||||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||||
отдан внешней программе, и она вне нашей границы.
|
отдан внешней программе, и она вне нашей границы;
|
||||||
|
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
|
||||||
|
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
|
||||||
|
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
|
||||||
|
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
|
||||||
|
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||||
|
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
|
||||||
|
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
|
||||||
|
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
|
||||||
|
как возврат недоступного проверке, а не как обычная работа.
|
||||||
|
|
||||||
**Перестали проверять сознательно:**
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
@@ -187,15 +342,445 @@ API и имя не откатываются обратной правкой по
|
|||||||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||||||
длительность от подставного источника. Своего теста у
|
длительность от подставного источника. Своего теста у
|
||||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
|
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||||
|
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||||
|
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||||
|
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||||
|
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
|
||||||
|
предметов осмотра нет с 2026-08-22.
|
||||||
|
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||||
|
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||||
|
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||||
|
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
|
||||||
|
|
||||||
|
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||||
|
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||||
|
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||||
|
остановку; нельзя — расшифровку и заливку.
|
||||||
|
|
||||||
|
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
|
||||||
|
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
|
||||||
|
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
|
||||||
|
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
|
||||||
|
заведение учётной записи первым обращением, отказ с недоверенного адреса и
|
||||||
|
отказ старта на пустом перечне.
|
||||||
|
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
|
||||||
|
контуре (см. «Не проверит ни один проход»).
|
||||||
|
|
||||||
## Журнал дефектов
|
## Журнал дефектов
|
||||||
|
|
||||||
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая —
|
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
|
||||||
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по
|
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
|
||||||
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и
|
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
|
||||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||||
выдумывать его задним числом нельзя.
|
оракул, и выдумывать оракул задним числом нельзя.
|
||||||
|
|
||||||
|
## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
|
||||||
|
`storage-without-pocketbase`
|
||||||
|
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
|
||||||
|
Путь под корнем приложения уходил в строку дословно — в том числе при ответе
|
||||||
|
`401`, потому что слой журнала стоит снаружи ограничителя частоты
|
||||||
|
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
|
||||||
|
для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
|
||||||
|
сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
|
||||||
|
под корнем распоряжается тот же аноним
|
||||||
|
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
|
||||||
|
прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
|
||||||
|
и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
|
||||||
|
- **Почему не поймали раньше:** правило записали по месту, где его впервые
|
||||||
|
понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
|
||||||
|
Зазор был ровно шириной в корень приложения
|
||||||
|
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
|
||||||
|
длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
|
||||||
|
закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
|
||||||
|
переписано на все корни разом — оно стоит теперь у строки о всяком входящем
|
||||||
|
запросе, а не у строки о раздаче приложения
|
||||||
|
|
||||||
|
## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
|
||||||
|
`storage-without-pocketbase`
|
||||||
|
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
|
||||||
|
бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
|
||||||
|
адресов
|
||||||
|
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
|
||||||
|
заголовок дописывает, а не заменяет. Левым значением распоряжается сам
|
||||||
|
спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
|
||||||
|
запросе
|
||||||
|
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
|
||||||
|
при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
|
||||||
|
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
|
||||||
|
свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
|
||||||
|
конвенции, ни в типовом узле — его держала прежде чужая библиотека
|
||||||
|
- **Что меняем:** цепочка читается справа налево, доверенные адреса
|
||||||
|
отбрасываются, ключом становится первый недоверенный, а заголовок читается
|
||||||
|
всеми строками, а не одной. Требование к контуру этим снято: дописывающий
|
||||||
|
прокси правилом покрыт — [security.md](security.md), «Периметр»
|
||||||
|
|
||||||
|
## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
|
||||||
|
`internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
|
||||||
|
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
|
||||||
|
и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
|
||||||
|
на инвариант как на оракул стало нельзя
|
||||||
|
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
|
||||||
|
инварианта остался от прежней. Мест при этом стало три: что спрошено
|
||||||
|
(`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
|
||||||
|
(`rowToAudioRecord`), — а сверялось правилом одно
|
||||||
|
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
|
||||||
|
`rowToAudioRecord` в `internal/archrules` — пусто
|
||||||
|
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
|
||||||
|
тексте — ничем. Текст и сторож разошлись молча
|
||||||
|
- **Что меняем:** инвариант назван действующими именами и действительным числом
|
||||||
|
мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
|
||||||
|
колонок чтения сверяется с перечнем присвоений в сущность
|
||||||
|
|
||||||
|
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
|
||||||
|
доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
|
||||||
|
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
|
||||||
|
местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
|
||||||
|
и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
|
||||||
|
журнале хранилища
|
||||||
|
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
|
||||||
|
найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
|
||||||
|
а признак провайдера на записи уникален — `idx_externalAuths_record_provider` —
|
||||||
|
и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
|
||||||
|
из `sub`
|
||||||
|
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
|
||||||
|
задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
|
||||||
|
читатели именно её
|
||||||
|
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
|
||||||
|
Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
|
||||||
|
затем счёт записей в коллекции пользователей. Само умолчание почты теперь
|
||||||
|
выводится из `-sub`
|
||||||
|
|
||||||
|
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
|
||||||
|
задача `spa-skeleton`
|
||||||
|
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
|
||||||
|
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
|
||||||
|
суток. При этом строка `docs/review.md`, добавленная той же задачей,
|
||||||
|
утверждала, что путь анонима в журнал не идёт
|
||||||
|
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
|
||||||
|
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
|
||||||
|
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
|
||||||
|
признак при переписывании не перенесён.
|
||||||
|
Журналов у сервиса два, а сделанная защита закрыла один
|
||||||
|
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
|
||||||
|
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
|
||||||
|
один тест туда не смотрел
|
||||||
|
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
|
||||||
|
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
|
||||||
|
два отказа оставляют
|
||||||
|
|
||||||
|
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
|
||||||
|
`json-api-for-spa`
|
||||||
|
- **Симптом:** три отказа под корнем приложения — превышение
|
||||||
|
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
|
||||||
|
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
|
||||||
|
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
|
||||||
|
заводилась
|
||||||
|
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
|
||||||
|
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
|
||||||
|
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
|
||||||
|
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
|
||||||
|
все три случая как закрытые
|
||||||
|
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
|
||||||
|
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
|
||||||
|
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
|
||||||
|
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
|
||||||
|
производителя в рабочем коде
|
||||||
|
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
|
||||||
|
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
|
||||||
|
вопросом в раздел ниже
|
||||||
|
|
||||||
|
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
|
||||||
|
и `StructureRepository.Put`; путь до них — `poll` → `storeOutcome` в
|
||||||
|
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
|
||||||
|
касался: она этот путь не трогала
|
||||||
|
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
|
||||||
|
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
|
||||||
|
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
|
||||||
|
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
|
||||||
|
готовности отдавал `done` без текста
|
||||||
|
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
|
||||||
|
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
|
||||||
|
и структура реплик такого условия не имели. Разное правило у двух хранителей
|
||||||
|
одного результата
|
||||||
|
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
|
||||||
|
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
|
||||||
|
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
|
||||||
|
он же проверяет, что до второго ответа дело действительно дошло
|
||||||
|
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
|
||||||
|
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
|
||||||
|
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
|
||||||
|
такого входа, а от чтения кода защита у соседа выглядела общей
|
||||||
|
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
|
||||||
|
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
|
||||||
|
текст, больше одного, и правило у одного из них у остальных читалось бы как
|
||||||
|
снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
|
||||||
|
задаче, которая его нашла
|
||||||
|
|
||||||
|
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
|
||||||
|
`docs/conventions/logging.md` и `docs/architecture.md`
|
||||||
|
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
|
||||||
|
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
|
||||||
|
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
|
||||||
|
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
|
||||||
|
вместе с ней — при том, что конвенция журнала называет пустой текст
|
||||||
|
распознавания поимённым примером уровня «может стать проблемой», а обзор
|
||||||
|
архитектуры обещал заглушку
|
||||||
|
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
|
||||||
|
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
|
||||||
|
готовую операцию с пустым результатом, проверка судит уровень строки и
|
||||||
|
идентификатор записи
|
||||||
|
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
|
||||||
|
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
|
||||||
|
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
|
||||||
|
что осталось в коде
|
||||||
|
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
|
||||||
|
идентификатором записи; строка обзора архитектуры переписана на фактическое
|
||||||
|
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
|
||||||
|
потребителем сигнала** — сигнал переживает канал только там, где его переносят
|
||||||
|
руками
|
||||||
|
|
||||||
|
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
|
||||||
|
попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода
|
||||||
|
сервиса он не касался
|
||||||
|
- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()`
|
||||||
|
не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею
|
||||||
|
- **Причина:** двойная, и каждая половина достаточна. Утверждение искало
|
||||||
|
подстроку `enabled = true при`, а в сообщении стоит `при enabled = true` —
|
||||||
|
порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:
|
||||||
|
`Validate()` отказывает **только** на пустом ключе, то есть значения, которым
|
||||||
|
можно проговориться, на этом пути не существует вовсе. Комментарий при этом
|
||||||
|
утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот
|
||||||
|
вход, который задан
|
||||||
|
- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил
|
||||||
|
тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`.
|
||||||
|
Проверка осталась зелёной
|
||||||
|
- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой,
|
||||||
|
что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали
|
||||||
|
два прохода независимо — разбор кода и сверка требований
|
||||||
|
- **Что меняем:** проверка переписана честно и переименована: половина требования
|
||||||
|
«сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а
|
||||||
|
настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ
|
||||||
|
в отказ попасть действительно может, в проверках отказа разбора файла настроек.
|
||||||
|
Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть»,
|
||||||
|
2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот
|
||||||
|
раз он другой природы: прежние два ловились правилом линтера про источник
|
||||||
|
утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать
|
||||||
|
значение, которое может утечь, иначе сторож пуст независимо от формы
|
||||||
|
утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь
|
||||||
|
вообще утечь» — суждение, а не форма. Остаётся проходу ревью
|
||||||
|
|
||||||
|
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
|
||||||
|
же правка, что проложила контекст до `ffmpeg`
|
||||||
|
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
|
||||||
|
выкладка посреди конвертации переводит здоровую запись в терминальное
|
||||||
|
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
|
||||||
|
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
|
||||||
|
идёт дольше часа по построению
|
||||||
|
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
|
||||||
|
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
|
||||||
|
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
|
||||||
|
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
|
||||||
|
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
|
||||||
|
успешным, и метрика владельца — та, которой он замечает отказы, — не
|
||||||
|
шевелилась
|
||||||
|
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
|
||||||
|
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
|
||||||
|
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
|
||||||
|
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
|
||||||
|
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
|
||||||
|
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
|
||||||
|
видит — это заметили три прохода ревью независимо, и все три построили путь
|
||||||
|
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
|
||||||
|
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
|
||||||
|
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
|
||||||
|
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
|
||||||
|
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
|
||||||
|
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
|
||||||
|
|
||||||
|
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
|
||||||
|
`file.Link(c.bot.Token)`
|
||||||
|
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
|
||||||
|
построчно. Первый же сбой сети на скачивании писал в журнал
|
||||||
|
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
|
||||||
|
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
|
||||||
|
конфиг» помечен critical и необратим: утёкший токен отзывают руками
|
||||||
|
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
|
||||||
|
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
|
||||||
|
оттуда — в `logger.Error` соседней строкой
|
||||||
|
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
|
||||||
|
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
|
||||||
|
запрещено
|
||||||
|
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
|
||||||
|
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
|
||||||
|
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
|
||||||
|
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||||
|
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||||
|
машина прозу не проверяет
|
||||||
|
- **Что меняем:** чистку перенесли с места употребления на **границу клиента** —
|
||||||
|
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||||
|
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||||
|
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||||
|
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
|
||||||
|
закрыто, оценка в [security.md](security.md) исправлена
|
||||||
|
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
|
||||||
|
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
|
||||||
|
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
|
||||||
|
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
|
||||||
|
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
|
||||||
|
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
|
||||||
|
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
|
||||||
|
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
|
||||||
|
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
|
||||||
|
осталось так
|
||||||
|
|
||||||
|
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
|
||||||
|
|
||||||
|
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
|
||||||
|
результата распознавания
|
||||||
|
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
|
||||||
|
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
|
||||||
|
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
|
||||||
|
как готовый результат
|
||||||
|
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
|
||||||
|
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
|
||||||
|
условие перестанет срабатывать вовсе, и оба исхода молчаливы
|
||||||
|
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
|
||||||
|
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
|
||||||
|
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
|
||||||
|
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
|
||||||
|
не спрашивало
|
||||||
|
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
|
||||||
|
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
|
||||||
|
|
||||||
|
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
|
||||||
|
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
|
||||||
|
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
|
||||||
|
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
|
||||||
|
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
|
||||||
|
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
|
||||||
|
успели снять его с прохода ревью
|
||||||
|
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
|
||||||
|
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
|
||||||
|
свойство
|
||||||
|
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
|
||||||
|
чтения живой карты: помечена была одна
|
||||||
|
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
|
||||||
|
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
||||||
|
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
||||||
|
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
||||||
|
проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
|
||||||
|
строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
|
||||||
|
стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
|
||||||
|
раздел снят 2026-08-13, урок остался здесь
|
||||||
|
|
||||||
|
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
|
||||||
|
в коллекции пользователей
|
||||||
|
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
|
||||||
|
владельца панели. Запись при первом входе заводит внутренний запрос самого
|
||||||
|
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
|
||||||
|
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
|
||||||
|
доступен только через Telegram, и чинилось бы это руками в панели
|
||||||
|
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
|
||||||
|
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
|
||||||
|
надёжным её закрытием и отвергает заодно единственный законный путь заведения
|
||||||
|
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
|
||||||
|
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
|
||||||
|
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
|
||||||
|
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
|
||||||
|
с открытым правилом возврат давал `302` и запись появлялась
|
||||||
|
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
|
||||||
|
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
|
||||||
|
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
|
||||||
|
исходникам библиотеки и враждебный проход падающим тестом
|
||||||
|
- **Что меняем:** правило сузили до контекста обмена
|
||||||
|
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
|
||||||
|
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
|
||||||
|
мимо входа, больше не считается покрытием входа
|
||||||
|
|
||||||
|
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
|
||||||
|
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
|
||||||
|
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
|
||||||
|
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
|
||||||
|
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
|
||||||
|
была зелёной и утверждала обратное
|
||||||
|
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
|
||||||
|
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
|
||||||
|
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
|
||||||
|
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
|
||||||
|
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
|
||||||
|
получит
|
||||||
|
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
|
||||||
|
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
|
||||||
|
показывал куку в `Header()` и не показывал в `Result()`
|
||||||
|
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
|
||||||
|
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
|
||||||
|
требований и разбор кода, — оба воспроизведением, а не чтением
|
||||||
|
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
|
||||||
|
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
|
||||||
|
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
|
||||||
|
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
|
||||||
|
судит по готовому ответу, а не по изменяемому состоянию обработчика.
|
||||||
|
Механизировано 2026-08-12 задачей `response-assertions-judge-result` —
|
||||||
|
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
|
||||||
|
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
|
||||||
|
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
|
||||||
|
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
|
||||||
|
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
|
||||||
|
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
|
||||||
|
`http.ResponseWriter`
|
||||||
|
|
||||||
|
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
|
||||||
|
хранилища на каждый вызов
|
||||||
|
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
|
||||||
|
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
|
||||||
|
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
|
||||||
|
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
|
||||||
|
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
|
||||||
|
величины, а обмен исполняется раньше обращения к провайдеру
|
||||||
|
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
|
||||||
|
не видно, что она правит приложение. Решение звать собственный адрес хранилища
|
||||||
|
внутри процесса сделало эту сборку частью горячего пути
|
||||||
|
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
|
||||||
|
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
|
||||||
|
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
|
||||||
|
недоступном провайдере утечка сохранялась
|
||||||
|
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
|
||||||
|
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
|
||||||
|
зондом, враждебный падающим тестом, сверка требований чтением
|
||||||
|
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
|
||||||
|
проверок добавлена та, что считает длину очереди обработчиков после двадцати
|
||||||
|
входов
|
||||||
|
|
||||||
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
||||||
|
|
||||||
@@ -219,6 +804,14 @@ API и имя не откатываются обратной правкой по
|
|||||||
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
||||||
ревью.
|
ревью.
|
||||||
|
|
||||||
|
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
|
||||||
|
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||||
|
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||||
|
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||||
|
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
|
||||||
|
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
|
||||||
|
комментариями в самом скрипте.
|
||||||
|
|
||||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||||
|
|
||||||
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
||||||
|
|||||||
+309
-98
@@ -2,14 +2,40 @@
|
|||||||
|
|
||||||
## Периметр
|
## Периметр
|
||||||
|
|
||||||
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
|
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||||
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
|
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||||||
этого — сегодняшнего — периметра.
|
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||||||
|
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||||||
|
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||||||
|
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||||||
|
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||||||
|
**каждый** запрос, и отзыв доступа действует со следующего.
|
||||||
|
|
||||||
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
|
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||||
вход для программ по личным токенам, два уровня доступа — пользователь видит
|
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||||
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
|
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||||||
описанное ниже разграничение доступа относится только к Telegram.
|
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||||||
|
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||||||
|
открытость не касается — всякий адрес под корнем приложения узнанного
|
||||||
|
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||||
|
|
||||||
|
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||||
|
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||||
|
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
|
||||||
|
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
|
||||||
|
браузеру сервису нечем.
|
||||||
|
|
||||||
|
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||||
|
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||||
|
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||||||
|
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
|
||||||
|
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||||
|
доступа — страницы расхода для владельца сервиса.
|
||||||
|
|
||||||
|
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
|
||||||
|
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
|
||||||
|
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
|
||||||
|
вместе с этим исключением.
|
||||||
|
|
||||||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||||||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||||||
@@ -17,20 +43,103 @@
|
|||||||
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
||||||
уходит содержимое записи» и «Что вне модели».
|
уходит содержимое записи» и «Что вне модели».
|
||||||
|
|
||||||
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
|
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
|
||||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
|
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
|
||||||
становится PocketBase, и вместе с ним на том же порту появляется панель по
|
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
|
||||||
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
|
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
|
||||||
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
|
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
|
||||||
панель через Authelia не пускает — у неё свой пароль суперпользователя.
|
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||||||
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
|
прокси нечего закрывать.
|
||||||
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
|
|
||||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
|
||||||
она вне модели («Что вне модели», строка про контур).
|
|
||||||
|
|
||||||
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
|
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||||||
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
|
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||||||
запросов и не ограничен по размеру файла.
|
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||||||
|
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||||||
|
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||||||
|
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||||||
|
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||||||
|
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||||||
|
|
||||||
|
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||||
|
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||||
|
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||||||
|
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||||||
|
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||||||
|
|
||||||
|
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||||||
|
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||||||
|
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||||||
|
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||||||
|
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
|
||||||
|
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
|
||||||
|
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
|
||||||
|
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
|
||||||
|
записью навсегда: колонка уникальна, а найденную запись узнавание не
|
||||||
|
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
|
||||||
|
правило решено распространить на всю тройку отдельной задачей. Остальное
|
||||||
|
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||||||
|
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||||||
|
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||||
|
человек.
|
||||||
|
|
||||||
|
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
|
||||||
|
задачей `config-test-headers-login`. При включённом предохранителе
|
||||||
|
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
|
||||||
|
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
|
||||||
|
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
|
||||||
|
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
|
||||||
|
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
|
||||||
|
самим сервисом —
|
||||||
|
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
|
||||||
|
|
||||||
|
Держится оно тремя вещами, и других нет: умолчание предохранителя —
|
||||||
|
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
|
||||||
|
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
|
||||||
|
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
|
||||||
|
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
|
||||||
|
входу это не служит: перечень доверенных адресов включению предохранителя не
|
||||||
|
мешает.
|
||||||
|
|
||||||
|
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
|
||||||
|
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
|
||||||
|
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
|
||||||
|
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
|
||||||
|
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
|
||||||
|
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||||
|
|
||||||
|
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||||||
|
Как именно читается цепочка, нормирует спека
|
||||||
|
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
|
||||||
|
пространством». Отсюда периметровое следствие: прокси, дописывающий
|
||||||
|
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
|
||||||
|
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
|
||||||
|
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
|
||||||
|
соединения.
|
||||||
|
|
||||||
|
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||||
|
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||||
|
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||||||
|
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||||||
|
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||||||
|
именно поэтому.
|
||||||
|
|
||||||
|
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||||||
|
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||||||
|
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||||||
|
попадает в **существующую** запись и получает весь её архив — самое
|
||||||
|
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||||||
|
может: неизменяемого признака заголовок не приносит. Не допускать
|
||||||
|
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||||||
|
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||||||
|
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||||||
|
нечем ни слить, ни убрать.
|
||||||
|
|
||||||
|
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||||
|
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||||
|
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
|
||||||
|
распознавание столько, сколько захочет: ограничитель частоты под корнем
|
||||||
|
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
|
||||||
|
по-прежнему нет — её заводит `per-user-size-quota`.
|
||||||
|
|
||||||
## Недоверенный вход
|
## Недоверенный вход
|
||||||
|
|
||||||
@@ -38,11 +147,12 @@
|
|||||||
|
|
||||||
| Вход | Канал | Кто может слать |
|
| Вход | Канал | Кто может слать |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
|
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
|
||||||
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
|
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
|
||||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
|
||||||
|
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||||
|
|
||||||
Что добавится вместе с целевым периметром — каждый вход появляется своей
|
Что добавится вместе с целевым периметром — каждый вход появляется своей
|
||||||
@@ -51,7 +161,6 @@
|
|||||||
| Вход | Канал | Кто может слать | Чья задача |
|
| Вход | Канал | Кто может слать | Чья задача |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||||||
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
|
|
||||||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||||||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||||||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||||||
@@ -63,9 +172,10 @@
|
|||||||
|
|
||||||
## Куда уходит содержимое записи
|
## Куда уходит содержимое записи
|
||||||
|
|
||||||
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
|
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||||
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
|
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||||
Telegram отправителю.
|
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||||
|
приложения.
|
||||||
|
|
||||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||||
|
|
||||||
@@ -85,27 +195,40 @@ Telegram отправителю.
|
|||||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||||
путь не предполагался.
|
путь не предполагался.
|
||||||
|
|
||||||
- **Путь на диске** выбирает хранилище:
|
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
|
||||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||||||
|
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||||||
|
роняет заведение временного файла.
|
||||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
|
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||||
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
|
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||||
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
|
распознавания.
|
||||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
|
||||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
|
||||||
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
|
||||||
что защищает `GET /api/status/:id`.
|
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
|
||||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
журнал, ни в метку метрики, ни в ответ.
|
||||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
|
||||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
|
||||||
есть доступны они только владельцу панели; проверено прогоном — записи отдают
|
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
|
||||||
`403`, служебные разделы `401`.
|
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
|
||||||
|
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
|
||||||
|
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
|
||||||
|
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
|
||||||
|
идёт расширение своим полем.
|
||||||
|
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
|
||||||
|
единственное, что защищает карточку записи, её текст и её файл сверх владения.
|
||||||
|
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
|
||||||
|
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
|
||||||
|
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
|
||||||
|
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
|
||||||
|
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
|
||||||
|
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
|
||||||
|
|
||||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||||
|
|
||||||
@@ -120,54 +243,110 @@ Telegram отправителю.
|
|||||||
|
|
||||||
## Что разграничивает доступ
|
## Что разграничивает доступ
|
||||||
|
|
||||||
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
|
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||||
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
|
отзыв доступа у Authelia действует со следующего обращения.
|
||||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
|
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||||||
остальным.
|
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||||||
|
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||||||
|
приоритета осталось бы правилом без предмета.
|
||||||
|
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||||||
|
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||||||
|
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||||||
|
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||||||
|
базе, а первый такой запрос с новым именем — записи в неё.
|
||||||
|
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||||
|
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||||
|
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||||||
|
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||||||
|
библиотечным правилом, не осталось.
|
||||||
|
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||||||
|
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||||||
|
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||||||
|
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||||||
|
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||||
|
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||||
|
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||||
|
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||||
|
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||||
|
2026-08-12.
|
||||||
|
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||||||
|
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||||||
|
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||||||
|
больше нечего, и адресов этих не существует.
|
||||||
|
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||||
|
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||||
|
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||||
|
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||||
|
эти адреса не несут.
|
||||||
|
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||||||
|
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||||
|
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||||
|
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||||||
|
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||||||
|
|
||||||
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
|
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||||
анонимен, знание UUID задачи и есть право её читать.
|
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||||
|
— читает её тот, кто её принёс.
|
||||||
|
|
||||||
Целевой периметр заводит четыре механизма вместо одного белого списка:
|
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||||||
|
них уже стоит:
|
||||||
|
|
||||||
| Механизм | Что даёт | Чья задача |
|
| Механизм | Что даёт | Чья задача |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` |
|
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||||
|
|
||||||
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
|
|
||||||
писать боту выводится из учётной записи (`telegram-account-link`).
|
|
||||||
|
|
||||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||||
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
|
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
|
||||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||||
(`admin-stats-screen`).
|
(`admin-stats-screen`).
|
||||||
|
|
||||||
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
|
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
|
||||||
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
|
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
|
||||||
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
|
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
|
||||||
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
|
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
|
||||||
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
|
сервиса осталось одно — владение записью.
|
||||||
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
|
|
||||||
задан неверно: сброса в наборе команд нет.
|
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
|
||||||
|
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
|
||||||
|
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
|
||||||
|
поверхности, открытой в интернет, у неё нет вовсе.
|
||||||
|
|
||||||
## Что чувствительнее чего
|
## Что чувствительнее чего
|
||||||
|
|
||||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||||
это самое чувствительное, что здесь есть.
|
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||||||
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
|
||||||
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||||||
|
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
|
||||||
|
полный текст речи — лежит файлом в подкаталоге записи**),
|
||||||
|
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||||
|
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
|
||||||
|
наравне с записью — норму держит спека `storage`.
|
||||||
|
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||||
Утечка оплачивается деньгами и доступом к бакету.
|
Утечка оплачивается деньгами и доступом к бакету.
|
||||||
4. **Белый список пользователей** — сам по себе перечень имён.
|
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||||||
|
из базы вместе с собственным входом.
|
||||||
|
|
||||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||||
|
|
||||||
|
**Строки `.env` в `.gitignore` и в `.dockerignore` стоят без читателя, и снимать
|
||||||
|
их поэтому нельзя.** Читателя сняли 2026-08-23 вместе с зависимостью
|
||||||
|
`godotenv`: наш рабочий код окружение не читает, и файл, положенный рядом с
|
||||||
|
бинарником, ничего не меняет. Барьеры остались против другого — против того,
|
||||||
|
чтобы секрет завёлся в этом файле руками и уехал из него теми же двумя путями,
|
||||||
|
какими уехал бы из конфига: в git и в контекст сборки образа. Второй барьер
|
||||||
|
нужен отдельно от первого: `Dockerfile` копирует корень целиком (`COPY . .`), а
|
||||||
|
`.gitignore` docker не читает — чужой `.env` лёг бы слоем образа. Инвариант,
|
||||||
|
ради которого барьеры стоят, — «Секрет не покидает конфиг» из
|
||||||
|
[../CLAUDE.md](../CLAUDE.md).
|
||||||
|
|
||||||
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
|
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
|
||||||
секрета, которого сегодня в проекте нет вовсе:
|
секрета, которого сегодня в проекте нет вовсе:
|
||||||
|
|
||||||
@@ -183,14 +362,10 @@ Telegram отправителю.
|
|||||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||||
открыта только владельцу.
|
открыта только владельцу.
|
||||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
заводить нечего.
|
||||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
|
||||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
|
||||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
|
||||||
читателю навсегда.
|
|
||||||
|
|
||||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||||
@@ -213,18 +388,31 @@ Telegram отправителю.
|
|||||||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||||
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||||||
по старому значению, покажет пустоту, и это не поломка.
|
по старому значению, покажет пустоту, и это не поломка.
|
||||||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||||
распоряжался бы анонимный отправитель.
|
распоряжался бы анонимный отправитель.
|
||||||
|
|
||||||
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
|
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
|
||||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
клиента — закрыты задачами `no-user-filename-in-log` и
|
||||||
типа файла не идёт.
|
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
|
||||||
|
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
|
||||||
|
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
|
||||||
|
|
||||||
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
|
||||||
нигде не логируется.
|
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||||
|
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
|
||||||
|
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
|
||||||
|
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
|
||||||
|
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
|
||||||
|
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
|
||||||
|
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
|
||||||
|
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
|
||||||
|
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
|
||||||
|
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
|
||||||
|
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
|
||||||
|
несёт значения **в нынешней версии** библиотеки.
|
||||||
|
|
||||||
## Что вне модели
|
## Что вне модели
|
||||||
|
|
||||||
@@ -232,6 +420,16 @@ Telegram отправителю.
|
|||||||
|
|
||||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||||
Ansible — не наша граница.
|
Ansible — не наша граница.
|
||||||
|
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
|
||||||
|
сам сервис: при включённом предохранителе `[server] debug` он подставляет
|
||||||
|
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
|
||||||
|
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
|
||||||
|
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
|
||||||
|
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
|
||||||
|
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
|
||||||
|
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
|
||||||
|
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
|
||||||
|
`./cmd/transcriber` поимённо.
|
||||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||||
доверяем полностью.
|
доверяем полностью.
|
||||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||||
@@ -239,12 +437,15 @@ Telegram отправителю.
|
|||||||
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
||||||
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
||||||
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
||||||
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не
|
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
|
||||||
замер: `research/` пуст, потолок длины стоит открытым вопросом
|
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
|
||||||
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
|
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||||||
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
|
||||||
открытый вопрос `architecture.md`.
|
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
|
||||||
|
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
|
||||||
|
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
|
||||||
|
ничем не наблюдается — открытый вопрос `architecture.md`.
|
||||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||||||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||||||
@@ -253,6 +454,16 @@ Telegram отправителю.
|
|||||||
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
||||||
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
||||||
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
||||||
Пока она не сделана, единственный способ убрать запись — руками в базе и в
|
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||||
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
|
строки потребления текста не содержат.
|
||||||
деньги потрачены, а строки потребления текста не содержат.
|
|
||||||
|
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||||||
|
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||||||
|
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||||||
|
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||||||
|
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||||||
|
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||||||
|
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||||||
|
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||||||
|
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||||||
|
`delete-record` это единственный способ, и он ручной целиком.
|
||||||
|
|||||||
@@ -1,81 +1,64 @@
|
|||||||
module git.vakhrushev.me/av/transcriber
|
module git.vakhrushev.me/av/transcriber
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/aws/smithy-go v1.27.7
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/pressly/goose/v3 v3.27.3
|
||||||
github.com/pocketbase/dbx v1.12.0
|
|
||||||
github.com/pocketbase/pocketbase v0.39.10
|
|
||||||
github.com/prometheus/client_golang v1.23.0
|
github.com/prometheus/client_golang v1.23.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0
|
github.com/yandex-cloud/go-genproto v0.17.0
|
||||||
google.golang.org/grpc v1.74.2
|
google.golang.org/grpc v1.82.1
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
modernc.org/sqlite v1.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
|
||||||
github.com/aws/smithy-go v1.27.7 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
|
|
||||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.65.0 // indirect
|
github.com/prometheus/common v0.65.0 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
golang.org/x/crypto v0.54.0 // indirect
|
|
||||||
golang.org/x/image v0.44.0 // indirect
|
|
||||||
golang.org/x/net v0.57.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
|
||||||
google.golang.org/protobuf v1.36.7 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
|
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
|
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
|
||||||
@@ -17,191 +12,138 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
|
|||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
|
||||||
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
|
|
||||||
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
|
||||||
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
|
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
|
||||||
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
|
||||||
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
|
|
||||||
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
|
|
||||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
|
||||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
|
||||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
@@ -212,8 +154,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
|||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -222,8 +164,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
|
|||||||
return &FfmpegConverter{}
|
return &FfmpegConverter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FfmpegConverter) Convert(src, dest string) error {
|
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
|
||||||
// Проверяем существование исходного файла
|
// Проверяем существование исходного файла
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("input file does not exist: %s", src)
|
return fmt.Errorf("input file does not exist: %s", src)
|
||||||
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
|
|||||||
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем команду ffmpeg для конвертации в OGG
|
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
|
||||||
cmd := exec.Command(ffmpegExecutable,
|
// оставляет его дожёвывать чужую запись после остановки воркера.
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegExecutable,
|
||||||
"-i", src, // входной файл
|
"-i", src, // входной файл
|
||||||
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
||||||
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
|
|||||||
return &FfmpegMetaViewer{}
|
return &FfmpegMetaViewer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
|
||||||
// Проверяем существование исходного файла
|
// Проверяем существование исходного файла
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("input file does not exist: %s", src)
|
return nil, fmt.Errorf("input file does not exist: %s", src)
|
||||||
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
|||||||
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем команду ffprobe для получения метаданных
|
// Команда заводится с контекстом: отправитель, закрывший соединение, не
|
||||||
cmd := exec.Command(ffprobeExecutable,
|
// оставляет за собой чтение метаданных чужого файла.
|
||||||
|
cmd := exec.CommandContext(ctx, ffprobeExecutable,
|
||||||
"-v", "quiet", // тихий режим (без лишнего вывода)
|
"-v", "quiet", // тихий режим (без лишнего вывода)
|
||||||
"-print_format", "json", // вывод в формате JSON
|
"-print_format", "json", // вывод в формате JSON
|
||||||
"-show_format", // показать информацию о формате
|
"-show_format", // показать информацию о формате
|
||||||
|
|||||||
@@ -1,22 +1,80 @@
|
|||||||
package recognizer
|
package recognizer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
|
||||||
|
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
|
||||||
|
// и хранение в Object Storage оплачиваются по факту.
|
||||||
|
//
|
||||||
|
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
|
||||||
|
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
|
||||||
|
// строится без единого обращения наружу.
|
||||||
type MemoryAudioRecognizer struct{}
|
type MemoryAudioRecognizer struct{}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
|
const memoryProvider = "memory"
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
|
||||||
|
return "memory://" + objectKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
|
||||||
return uuid.NewString(), nil
|
return uuid.NewString(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
|
func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
return "Foo bar, Baz.", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
|
||||||
return entity.NewCompletedResult(), nil
|
return entity.NewCompletedResult(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
|
replicas := []entity.Replica{
|
||||||
|
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
|
||||||
|
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(replicas)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to encode memory payload")
|
||||||
|
}
|
||||||
|
return &entity.RecognitionOutcome{
|
||||||
|
Replicas: replicas,
|
||||||
|
PlainText: "Foo bar, Baz.",
|
||||||
|
Raw: raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||||
|
var replicas []entity.Replica
|
||||||
|
if err := json.Unmarshal(raw, &replicas); err != nil {
|
||||||
|
return nil, errors.New("failed to decode memory payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain []byte
|
||||||
|
for _, replica := range replicas {
|
||||||
|
if len(plain) > 0 {
|
||||||
|
plain = append(plain, ' ')
|
||||||
|
}
|
||||||
|
plain = append(plain, replica.Text...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.RecognitionOutcome{
|
||||||
|
Replicas: replicas,
|
||||||
|
PlainText: string(plain),
|
||||||
|
Raw: raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package yandex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
|
||||||
|
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
|
||||||
|
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
|
||||||
|
// сохранность самого ответа.
|
||||||
|
|
||||||
|
// response собирает ответ потока с одной репликой.
|
||||||
|
func response(text string, start, end int64) *stt.StreamingResponse {
|
||||||
|
return &stt.StreamingResponse{
|
||||||
|
Event: &stt.StreamingResponse_FinalRefinement{
|
||||||
|
FinalRefinement: &stt.FinalRefinement{
|
||||||
|
Type: &stt.FinalRefinement_NormalizedText{
|
||||||
|
NormalizedText: &stt.AlternativeUpdate{
|
||||||
|
Alternatives: []*stt.Alternative{
|
||||||
|
{Text: text, StartTimeMs: start, EndTimeMs: end},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
|
||||||
|
func TestPayloadSurvivesRoundTrip(t *testing.T) {
|
||||||
|
responses := []*stt.StreamingResponse{
|
||||||
|
response("Первая реплика.", 0, 900),
|
||||||
|
response("Вторая реплика.", 900, 1800),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := encodeResponses(responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, raw)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, decoded, 2)
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
require.Len(t, outcome.Replicas, 2)
|
||||||
|
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
|
||||||
|
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
|
||||||
|
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
|
||||||
|
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
|
||||||
|
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
|
||||||
|
// без предупреждения, и текстовое представление, собранное по нашей
|
||||||
|
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
|
||||||
|
// уже не из чего: операция не переспрашивается.
|
||||||
|
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
|
||||||
|
original := response("Реплика.", 0, 500)
|
||||||
|
|
||||||
|
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
|
||||||
|
// разбор положил в неизвестные.
|
||||||
|
unknown := protoimplUnknown(t)
|
||||||
|
original.ProtoReflect().SetUnknown(unknown)
|
||||||
|
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
|
||||||
|
|
||||||
|
raw, err := encodeResponses([]*stt.StreamingResponse{original})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, decoded, 1)
|
||||||
|
|
||||||
|
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
|
||||||
|
"неизвестное провайдерское поле пережило запись и чтение")
|
||||||
|
|
||||||
|
// И известное при этом на месте.
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
require.Len(t, outcome.Replicas, 1)
|
||||||
|
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
|
||||||
|
// текста, выданная за целую, тише и хуже отказа.
|
||||||
|
func TestTruncatedPayloadIsRefused(t *testing.T) {
|
||||||
|
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, len(raw), 2)
|
||||||
|
|
||||||
|
_, err = decodeResponses(raw[:len(raw)-2])
|
||||||
|
assert.Error(t, err, "обрезанное вложение не разбирается молча")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
|
||||||
|
// законный исход распознавания.
|
||||||
|
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
|
||||||
|
raw, err := encodeResponses(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, decoded)
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
assert.Empty(t, outcome.Replicas)
|
||||||
|
assert.Empty(t, outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
|
||||||
|
// потоку и служебные события.
|
||||||
|
func TestResponseWithoutTextIsSkipped(t *testing.T) {
|
||||||
|
responses := []*stt.StreamingResponse{
|
||||||
|
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
|
||||||
|
response("Реплика.", 0, 500),
|
||||||
|
response("", 500, 600),
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
|
||||||
|
assert.Equal(t, "Реплика.", outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
|
||||||
|
// нашей схеме нет, с целочисленным значением.
|
||||||
|
func protoimplUnknown(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
|
||||||
|
raw, err := proto.Marshal(&stt.StreamingResponse{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, raw)
|
||||||
|
|
||||||
|
return []byte{0xF8, 0xFF, 0x3F, 0x07}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
package yandex
|
package yandex
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
|
||||||
|
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
|
||||||
|
const ProviderName = "yandex-speechkit"
|
||||||
|
|
||||||
type YandexAudioRecognizerConfig struct {
|
type YandexAudioRecognizerConfig struct {
|
||||||
// s3
|
// s3
|
||||||
Region string
|
Region string
|
||||||
@@ -54,29 +60,74 @@ func (s *YandexAudioRecognizerService) Close() error {
|
|||||||
return s.sttService.Close()
|
return s.sttService.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
|
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
|
||||||
|
|
||||||
err := s.s3Sevice.uploadFile(file, fileName)
|
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
|
||||||
if err != nil {
|
|
||||||
|
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
||||||
|
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
||||||
|
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
||||||
|
const startRecognitionTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Upload кладёт аудио туда, откуда провайдер его прочитает.
|
||||||
|
//
|
||||||
|
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
|
||||||
|
// ложится под тем же ключом.
|
||||||
|
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
|
||||||
|
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
return s.s3Sevice.fileUrl(objectKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
uri := s.s3Sevice.fileUrl(fileName)
|
// ObjectExists отвечает, лежит ли объект нужного размера.
|
||||||
|
//
|
||||||
|
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
|
||||||
|
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
|
||||||
|
// расхождение на всякой большой записи.
|
||||||
|
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
return s.s3Sevice.objectExists(ctx, objectKey, size)
|
||||||
|
}
|
||||||
|
|
||||||
opId, err := s.sttService.recognizeFileFromS3(uri)
|
// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
|
||||||
|
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
|
||||||
|
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
|
||||||
|
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
|
||||||
|
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
|
||||||
|
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
||||||
|
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
|
||||||
|
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
|
||||||
|
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
|
||||||
|
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
|
||||||
|
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
|
||||||
|
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
|
return s.sttService.fetchRecognition(ctx, operationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
|
||||||
|
// провайдеру. По нему архив пересчитывается без единого рубля.
|
||||||
|
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||||
|
responses, err := decodeResponses(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
|
}
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
outcome.Raw = raw
|
||||||
|
return outcome, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return opId, nil
|
func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
}
|
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
|
|
||||||
return s.sttService.getRecognitionText(operationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
|
||||||
operation, err := s.sttService.checkOperationStatus(operationID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package yandex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Принятие операции распознавания защищено от отмены: остановка сервиса не
|
||||||
|
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
|
||||||
|
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
|
||||||
|
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
|
||||||
|
// ключах запрещён (CLAUDE.md, «Запреты»).
|
||||||
|
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
|
||||||
|
parent, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
|
protected, release := protectFromCancel(parent, time.Minute)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
|
||||||
|
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
|
||||||
|
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
|
||||||
|
protected, release := protectFromCancel(t.Context(), time.Minute)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
deadline, ok := protected.Deadline()
|
||||||
|
|
||||||
|
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
|
||||||
|
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"github.com/aws/smithy-go"
|
"github.com/aws/smithy-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,8 +68,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
|
||||||
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
|
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(s.bucketName),
|
Bucket: aws.String(s.bucketName),
|
||||||
Key: aws.String(fileName),
|
Key: aws.String(fileName),
|
||||||
Body: file,
|
Body: file,
|
||||||
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectExists отвечает, лежит ли объект нужного размера.
|
||||||
|
//
|
||||||
|
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
|
||||||
|
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
|
||||||
|
// отпечатку содержимого: признак целостности у составного объекта не равен
|
||||||
|
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
|
||||||
|
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(s.bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var notFound *s3types.NotFound
|
||||||
|
if errors.As(err, ¬Found) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
|
||||||
|
// идёт класс отказа и только он.
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
|
||||||
|
}
|
||||||
|
return false, errors.New("failed to head object in S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.ContentLength != nil && *out.ContentLength == size, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *yandexS3Service) fileUrl(fileName string) string {
|
func (s *yandexS3Service) fileUrl(fileName string) string {
|
||||||
endpoint := strings.TrimRight(s.endpoint, "/")
|
endpoint := strings.TrimRight(s.endpoint, "/")
|
||||||
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
|
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ package yandex
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"io"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
||||||
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
|
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -93,9 +97,7 @@ func (s *speechKitService) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
||||||
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// Добавляем авторизацию и folder_id в контекст
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
@@ -135,11 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
|||||||
return op.Id, nil
|
return op.Id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecognitionResult получает результат распознавания по ID операции
|
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
|
||||||
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
|
// вместе с сырым ответом на хранение.
|
||||||
ctx := context.Background()
|
//
|
||||||
|
// Ответ сохраняется потому, что **результат операции у провайдера не
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
|
||||||
|
// когда научится, архив пересчитается из сохранённого без единого рубля.
|
||||||
|
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
|
|
||||||
@@ -149,36 +153,36 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
|||||||
|
|
||||||
stream, err := s.sttClient.GetRecognition(ctx, req)
|
stream, err := s.sttClient.GetRecognition(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get recognition stream: %w", err)
|
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var responses []*stt.StreamingResponse
|
||||||
|
|
||||||
for {
|
for {
|
||||||
resp, err := stream.Recv()
|
resp, err := stream.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "EOF" {
|
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
|
||||||
|
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
|
||||||
|
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
|
||||||
}
|
|
||||||
if refinement := resp.GetFinalRefinement(); refinement != nil {
|
|
||||||
if text := refinement.GetNormalizedText(); text != nil {
|
|
||||||
for _, alt := range text.Alternatives {
|
|
||||||
sb.WriteString(alt.Text)
|
|
||||||
sb.WriteString(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
responses = append(responses, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String(), nil
|
raw, err := encodeResponses(responses)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
outcome.Raw = raw
|
||||||
|
return outcome, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOperationStatus проверяет статус операции распознавания
|
// checkOperationStatus проверяет статус операции распознавания
|
||||||
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
|
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
|
|
||||||
@@ -192,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(operationID string) (*operation.
|
|||||||
|
|
||||||
return op, nil
|
return op, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
|
||||||
|
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
|
||||||
|
//
|
||||||
|
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
|
||||||
|
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
|
||||||
|
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
|
||||||
|
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
|
||||||
|
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
|
||||||
|
// результат операции у провайдера не переспрашивается, и повторное
|
||||||
|
// распознавание стоит денег.
|
||||||
|
//
|
||||||
|
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
|
||||||
|
// кроме нашего же кода.
|
||||||
|
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
|
||||||
|
var raw []byte
|
||||||
|
for _, resp := range responses {
|
||||||
|
encoded, err := proto.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
// Текст расшифровки наружу не выходит даже отказом: сообщение
|
||||||
|
// провайдера несёт её целиком.
|
||||||
|
return nil, errors.New("failed to encode provider response")
|
||||||
|
}
|
||||||
|
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
|
||||||
|
raw = append(raw, encoded...)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeResponses читает сохранённый ответ провайдера обратно.
|
||||||
|
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
|
||||||
|
var responses []*stt.StreamingResponse
|
||||||
|
|
||||||
|
for len(raw) > 0 {
|
||||||
|
size, read := binary.Uvarint(raw)
|
||||||
|
if read <= 0 || uint64(len(raw)-read) < size {
|
||||||
|
return nil, errors.New("stored provider payload is truncated")
|
||||||
|
}
|
||||||
|
raw = raw[read:]
|
||||||
|
|
||||||
|
var resp stt.StreamingResponse
|
||||||
|
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
|
||||||
|
return nil, errors.New("failed to decode provider response")
|
||||||
|
}
|
||||||
|
responses = append(responses, &resp)
|
||||||
|
raw = raw[size:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
|
||||||
|
// текст. Формата провайдера отсюда наружу не выходит ничего.
|
||||||
|
//
|
||||||
|
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
|
||||||
|
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
|
||||||
|
// станет возможной без повторной оплаты.
|
||||||
|
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
|
||||||
|
outcome := &entity.RecognitionOutcome{}
|
||||||
|
|
||||||
|
var plain []byte
|
||||||
|
for _, resp := range responses {
|
||||||
|
refinement := resp.GetFinalRefinement()
|
||||||
|
if refinement == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := refinement.GetNormalizedText()
|
||||||
|
if text == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, alt := range text.GetAlternatives() {
|
||||||
|
if alt.GetText() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outcome.Replicas = append(outcome.Replicas, entity.Replica{
|
||||||
|
StartMs: alt.GetStartTimeMs(),
|
||||||
|
EndMs: alt.GetEndTimeMs(),
|
||||||
|
Text: alt.GetText(),
|
||||||
|
})
|
||||||
|
if len(plain) > 0 {
|
||||||
|
plain = append(plain, ' ')
|
||||||
|
}
|
||||||
|
plain = append(plain, alt.GetText()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome.PlainText = string(plain)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
|
|
||||||
//
|
|
||||||
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
|
|
||||||
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
|
|
||||||
// контракт запуска.
|
|
||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
pb "github.com/pocketbase/pocketbase"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
|
|
||||||
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
|
|
||||||
const (
|
|
||||||
FilesCollection = "files"
|
|
||||||
JobsCollection = "transcribe_jobs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
|
|
||||||
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
|
|
||||||
// шаги схемы.
|
|
||||||
//
|
|
||||||
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
|
|
||||||
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
|
|
||||||
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
|
|
||||||
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
|
|
||||||
func New(dataDir string) (*pb.PocketBase, error) {
|
|
||||||
app := pb.NewWithConfig(pb.Config{
|
|
||||||
DefaultDataDir: dataDir,
|
|
||||||
HideStartBanner: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := app.Bootstrap(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.RunAllMigrations(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
|
|
||||||
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
|
|
||||||
// потому что Serve накатывает схему прежде, чем поднять сервер.
|
|
||||||
func findCollection(app core.App, name string) (*core.Collection, error) {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
|
|
||||||
}
|
|
||||||
return collection, nil
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
|
|
||||||
// системы, а не в каталоге данных: последний смонтирован на сервере, и
|
|
||||||
// временному там не место.
|
|
||||||
type workFile struct {
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *workFile) Path() string { return w.path }
|
|
||||||
|
|
||||||
func (w *workFile) Size() (int64, error) {
|
|
||||||
info, err := os.Stat(w.path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to stat work file: %w", err)
|
|
||||||
}
|
|
||||||
return info.Size(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
|
|
||||||
// до его создания, и повторный Close тоже законен.
|
|
||||||
func (w *workFile) Close() error {
|
|
||||||
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("failed to remove work file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileRepository(app core.App) *FileRepository {
|
|
||||||
return &FileRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
|
|
||||||
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
|
|
||||||
func newWorkFile(ext string) (*workFile, error) {
|
|
||||||
f, err := os.CreateTemp("", "transcriber-*"+ext)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create work file: %w", err)
|
|
||||||
}
|
|
||||||
path := f.Name()
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
_ = os.Remove(path)
|
|
||||||
return nil, fmt.Errorf("failed to close work file: %w", err)
|
|
||||||
}
|
|
||||||
return &workFile{path: path}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
|
|
||||||
return newWorkFile(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
|
|
||||||
work, err := newWorkFile(ext)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeTo(work.path, content); err != nil {
|
|
||||||
// Отказ уборки не подменяет отказ записи, но и не теряется.
|
|
||||||
return nil, errors.Join(err, work.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
return work, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := firstFileName(record)
|
|
||||||
if name == "" {
|
|
||||||
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
work, err := newWorkFile(filepath.Ext(name))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := repo.openStored(record, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Join(err, work.Close())
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
if err := writeTo(work.path, src); err != nil {
|
|
||||||
return nil, errors.Join(err, work.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
return work, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
|
|
||||||
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
|
||||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
|
||||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
|
||||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
|
||||||
collection, err := findCollection(repo.app, FilesCollection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stored, err := filesystem.NewFileFromPath(work.Path())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read work file: %w", err)
|
|
||||||
}
|
|
||||||
stored.Name = name
|
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("file", stored)
|
|
||||||
record.Set("location", entity.LocationLocal)
|
|
||||||
record.Set("size", stored.Size)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
// Отказ укладки называет имя файла — то самое, из которого строится
|
|
||||||
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
|
|
||||||
// ключ при чтении.
|
|
||||||
return nil, errors.New("failed to store file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return recordToFile(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
|
||||||
collection, err := findCollection(repo.app, FilesCollection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("location", entity.LocationS3)
|
|
||||||
record.Set("object_key", objectKey)
|
|
||||||
record.Set("size", size)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return recordToFile(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
|
||||||
}
|
|
||||||
return recordToFile(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := firstFileName(record)
|
|
||||||
if name == "" {
|
|
||||||
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return repo.openStored(record, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openStored открывает содержимое файла в хранилище потоком.
|
|
||||||
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
|
|
||||||
fsys, err := repo.app.NewFilesystem()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
|
|
||||||
if err != nil {
|
|
||||||
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
|
|
||||||
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
|
|
||||||
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
|
|
||||||
// стала бы там бессрочным ключом к чужому аудио.
|
|
||||||
return nil, errors.Join(
|
|
||||||
fmt.Errorf("failed to read stored file of record %s", record.Id),
|
|
||||||
fsys.Close(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &storedReader{reader: reader, fsys: fsys}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedReader держит открытой файловую систему хранилища на всё время чтения:
|
|
||||||
// закрытая раньше времени, она обрывает поток на середине записи.
|
|
||||||
type storedReader struct {
|
|
||||||
reader io.ReadCloser
|
|
||||||
fsys io.Closer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
|
||||||
|
|
||||||
func (r *storedReader) Close() error {
|
|
||||||
readerErr := r.reader.Close()
|
|
||||||
fsysErr := r.fsys.Close()
|
|
||||||
switch {
|
|
||||||
case readerErr != nil && fsysErr != nil:
|
|
||||||
return errors.New("failed to close stored file and its filesystem")
|
|
||||||
case readerErr != nil:
|
|
||||||
return errors.New("failed to close stored file")
|
|
||||||
default:
|
|
||||||
return fsysErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeTo переливает содержимое в файл потоком. В память запись целиком не
|
|
||||||
// читается: расчётный потолок — шесть часов.
|
|
||||||
func writeTo(path string, content io.Reader) error {
|
|
||||||
dst, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open work file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(dst, content); err != nil {
|
|
||||||
_ = dst.Close()
|
|
||||||
return fmt.Errorf("failed to write work file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dst.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close work file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstFileName(record *core.Record) string {
|
|
||||||
names := record.GetStringSlice("file")
|
|
||||||
if len(names) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return names[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordToFile(record *core.Record) *entity.File {
|
|
||||||
name := firstFileName(record)
|
|
||||||
if name == "" {
|
|
||||||
name = record.GetString("object_key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entity.File{
|
|
||||||
Id: record.Id,
|
|
||||||
Location: record.GetString("location"),
|
|
||||||
FileName: name,
|
|
||||||
Size: int64(record.GetInt("size")),
|
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
|
|
||||||
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
|
|
||||||
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
|
|
||||||
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
|
|
||||||
// иначе прошло бы молча.
|
|
||||||
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewFileRepository(app)
|
|
||||||
|
|
||||||
const libraryDefault = 5 << 20
|
|
||||||
|
|
||||||
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
|
|
||||||
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
|
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
|
||||||
|
|
||||||
size, err := work.Size()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
|
||||||
|
|
||||||
file, err := repo.CreateLocal("big.mp3", work)
|
|
||||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
|
||||||
assert.Equal(t, size, file.Size)
|
|
||||||
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
|
|
||||||
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
|
|
||||||
// расхождение проявлялось как потерянное при сохранении поле.
|
|
||||||
|
|
||||||
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
|
|
||||||
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
|
|
||||||
// каким входом пришла запись, — не трогаются вовсе.
|
|
||||||
//
|
|
||||||
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
|
|
||||||
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
|
|
||||||
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
|
|
||||||
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
|
|
||||||
// правка на месте.
|
|
||||||
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
|
|
||||||
record.Set("state", job.State)
|
|
||||||
record.Set("file", derefString(job.FileID))
|
|
||||||
record.Set("error_text", derefString(job.ErrorText))
|
|
||||||
record.Set("acquisition_id", derefString(job.AcquisitionID))
|
|
||||||
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
|
|
||||||
record.Set("delay_time", dateOrEmpty(job.DelayTime))
|
|
||||||
record.Set("attempts", job.Attempts)
|
|
||||||
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
|
|
||||||
record.Set("transcription_text", derefString(job.TranscriptionText))
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
|
|
||||||
// поля здесь не с кем.
|
|
||||||
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
|
|
||||||
applyOwnedByPipeline(record, job)
|
|
||||||
record.Set("source", job.Source)
|
|
||||||
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
|
||||||
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordToJob(record *core.Record) *entity.TranscribeJob {
|
|
||||||
return &entity.TranscribeJob{
|
|
||||||
Id: record.Id,
|
|
||||||
State: record.GetString("state"),
|
|
||||||
Source: record.GetString("source"),
|
|
||||||
FileID: nilIfEmpty(record.GetString("file")),
|
|
||||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
|
||||||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
|
||||||
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
|
|
||||||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
|
||||||
Attempts: record.GetInt("attempts"),
|
|
||||||
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
|
|
||||||
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
|
|
||||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
|
||||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
|
||||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
|
|
||||||
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
|
|
||||||
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
|
|
||||||
// целиком.
|
|
||||||
type acquiredRow struct {
|
|
||||||
Id string `db:"id"`
|
|
||||||
State string `db:"state"`
|
|
||||||
Source string `db:"source"`
|
|
||||||
FileID sql.NullString `db:"file"`
|
|
||||||
ErrorText sql.NullString `db:"error_text"`
|
|
||||||
AcquisitionID sql.NullString `db:"acquisition_id"`
|
|
||||||
AcquireTime sql.NullString `db:"acquire_time"`
|
|
||||||
DelayTime sql.NullString `db:"delay_time"`
|
|
||||||
Attempts int `db:"attempts"`
|
|
||||||
RecognitionOpID sql.NullString `db:"recognition_op_id"`
|
|
||||||
TranscriptionText sql.NullString `db:"transcription_text"`
|
|
||||||
TgChatId sql.NullInt64 `db:"tg_chat_id"`
|
|
||||||
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
|
|
||||||
Created sql.NullString `db:"created"`
|
|
||||||
Updated sql.NullString `db:"updated"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *acquiredRow) toJob() *entity.TranscribeJob {
|
|
||||||
job := &entity.TranscribeJob{
|
|
||||||
Id: r.Id,
|
|
||||||
State: r.State,
|
|
||||||
Source: r.Source,
|
|
||||||
FileID: nullToPtr(r.FileID),
|
|
||||||
ErrorText: nullToPtr(r.ErrorText),
|
|
||||||
AcquisitionID: nullToPtr(r.AcquisitionID),
|
|
||||||
AcquireTime: parseTimeOrNil(r.AcquireTime),
|
|
||||||
DelayTime: parseTimeOrNil(r.DelayTime),
|
|
||||||
Attempts: r.Attempts,
|
|
||||||
RecognitionOpID: nullToPtr(r.RecognitionOpID),
|
|
||||||
TranscriptionText: nullToPtr(r.TranscriptionText),
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
|
|
||||||
chatId := r.TgChatId.Int64
|
|
||||||
job.TgChatId = &chatId
|
|
||||||
}
|
|
||||||
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
|
|
||||||
msgId := int(r.TgReplyMessageId.Int64)
|
|
||||||
job.TgReplyMessageId = &msgId
|
|
||||||
}
|
|
||||||
if created := parseTimeOrNil(r.Created); created != nil {
|
|
||||||
job.CreatedAt = *created
|
|
||||||
}
|
|
||||||
if updated := parseTimeOrNil(r.Updated); updated != nil {
|
|
||||||
job.UpdatedAt = *updated
|
|
||||||
}
|
|
||||||
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefString(v *string) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefInt64(v *int64) int64 {
|
|
||||||
if v == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefInt(v *int) int {
|
|
||||||
if v == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
|
||||||
// хранилище это пустая строка, и она же значит «времени нет».
|
|
||||||
func dateOrEmpty(v *time.Time) any {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
date, err := types.ParseDateTime(*v)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
func nilIfEmpty(v string) *string {
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeOrNil(v types.DateTime) *time.Time {
|
|
||||||
if v.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := v.Time()
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func nilIfZero64(v int64) *int64 {
|
|
||||||
if v == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
func nilIfZeroInt(v int) *int {
|
|
||||||
if v == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullToPtr(v sql.NullString) *string {
|
|
||||||
if !v.Valid || v.String == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s := v.String
|
|
||||||
return &s
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTimeOrNil(v sql.NullString) *time.Time {
|
|
||||||
if !v.Valid || v.String == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
date, err := types.ParseDateTime(v.String)
|
|
||||||
if err != nil || date.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := date.Time()
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/migrations"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
|
||||||
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
|
||||||
// применённое по имени файла шага.
|
|
||||||
//
|
|
||||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
|
||||||
// `apis.Serve` прежде, чем поднять сервер.
|
|
||||||
func init() {
|
|
||||||
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
|
||||||
}
|
|
||||||
|
|
||||||
func up202608110001(app core.App) error {
|
|
||||||
files := core.NewBaseCollection(FilesCollection)
|
|
||||||
files.Fields.Add(
|
|
||||||
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
|
|
||||||
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
|
|
||||||
// готовности задачи, а не ниже.
|
|
||||||
//
|
|
||||||
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
|
|
||||||
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
|
|
||||||
// минут. Число выведено из расчётного потолка записи в шесть часов с
|
|
||||||
// запасом на видео; оно же стоит строкой в docs/database.md.
|
|
||||||
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
|
|
||||||
// Где лежит копия. Поле названо `location`, а не `storage`: последним
|
|
||||||
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
|
|
||||||
// разным вещам.
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "location",
|
|
||||||
Values: []string{entity.LocationLocal, entity.LocationS3},
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
// Ключ объекта во внешнем хранилище; у местной копии пуст.
|
|
||||||
&core.TextField{Name: "object_key"},
|
|
||||||
&core.NumberField{Name: "size", OnlyInt: true},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Save(files); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := core.NewBaseCollection(JobsCollection)
|
|
||||||
jobs.Fields.Add(
|
|
||||||
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
|
|
||||||
// не должна попасть в выборку с состоянием, которого конвейер не знает.
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "state",
|
|
||||||
Values: []string{
|
|
||||||
entity.StateCreated,
|
|
||||||
entity.StateConverted,
|
|
||||||
entity.StateTranscribe,
|
|
||||||
entity.StateDone,
|
|
||||||
entity.StateFailed,
|
|
||||||
entity.StateDead,
|
|
||||||
},
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
&core.SelectField{
|
|
||||||
Name: "source",
|
|
||||||
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
|
|
||||||
// результат.
|
|
||||||
// Обязательна: задача без записи не может пройти ни одного шага, и
|
|
||||||
// заведённая в панели руками она дошла бы до шага только затем, чтобы
|
|
||||||
// отказать. Компилятор этого не держит — держит схема.
|
|
||||||
&core.RelationField{
|
|
||||||
Name: "file",
|
|
||||||
CollectionId: files.Id,
|
|
||||||
MaxSelect: 1,
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
&core.TextField{Name: "error_text"},
|
|
||||||
&core.TextField{Name: "acquisition_id"},
|
|
||||||
&core.DateField{Name: "acquire_time"},
|
|
||||||
&core.DateField{Name: "delay_time"},
|
|
||||||
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
|
|
||||||
// завершившемся без отказа.
|
|
||||||
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
|
|
||||||
&core.TextField{Name: "recognition_op_id"},
|
|
||||||
&core.EditorField{Name: "transcription_text"},
|
|
||||||
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
|
|
||||||
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
|
|
||||||
&core.AutodateField{Name: "created", OnCreate: true},
|
|
||||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
|
|
||||||
// состоянию снимает полный перебор, который был у прежней таблицы.
|
|
||||||
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
|
|
||||||
|
|
||||||
return app.Save(jobs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func down202608110001(app core.App) error {
|
|
||||||
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
|
|
||||||
for _, name := range []string{JobsCollection, FilesCollection} {
|
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := app.Delete(collection); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
|
||||||
// и правку из кода.
|
|
||||||
//
|
|
||||||
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
|
|
||||||
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
|
|
||||||
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
|
|
||||||
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
|
|
||||||
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
|
|
||||||
// от первого же отказа). Узнать об этом ему неоткуда.
|
|
||||||
//
|
|
||||||
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
|
||||||
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
|
||||||
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
|
|
||||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
|
||||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
|
||||||
func BindPanelRules(app core.App) {
|
|
||||||
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
|
||||||
original := e.Record.Original()
|
|
||||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Record.Set("acquisition_id", "")
|
|
||||||
e.Record.Set("acquire_time", "")
|
|
||||||
e.Record.Set("delay_time", "")
|
|
||||||
e.Record.Set("attempts", 0)
|
|
||||||
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TranscriptJobRepository struct {
|
|
||||||
app core.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
|
|
||||||
return &TranscriptJobRepository{app: app}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
|
||||||
collection, err := findCollection(repo.app, JobsCollection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
if job.Id != "" {
|
|
||||||
record.Id = job.Id
|
|
||||||
}
|
|
||||||
applyToRecord(record, job)
|
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to insert transcribe job: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
job.Id = record.Id
|
|
||||||
job.CreatedAt = record.GetDateTime("created").Time()
|
|
||||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
|
|
||||||
// одной транзакцией: шаг, потерявший задачу за время работы, получает
|
|
||||||
// LostAcquisitionError и результата не пишет.
|
|
||||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
|
||||||
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
|
||||||
record, err := txApp.FindRecordById(JobsCollection, job.Id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find transcribe job: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if holder != "" && record.GetString("acquisition_id") != holder {
|
|
||||||
return &contract.LostAcquisitionError{JobID: job.Id}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кладём только то, чем распоряжается конвейер: правку владельца в
|
|
||||||
// панели снимок шага стирать не должен.
|
|
||||||
applyOwnedByPipeline(record, job)
|
|
||||||
|
|
||||||
if err := txApp.Save(record); err != nil {
|
|
||||||
return fmt.Errorf("failed to update transcribe job: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
|
||||||
record, err := repo.app.FindRecordById(JobsCollection, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
|
||||||
}
|
|
||||||
return recordToJob(record), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
|
|
||||||
// отдал бы и порядок, зависящий от схемы.
|
|
||||||
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
|
|
||||||
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
|
|
||||||
`tg_chat_id, tg_reply_message_id, created, updated`
|
|
||||||
|
|
||||||
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
|
|
||||||
// пометка её захваченной идут вместе, и захваченная возвращается тем же
|
|
||||||
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
|
|
||||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
|
||||||
//
|
|
||||||
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
|
|
||||||
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
|
|
||||||
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
|
|
||||||
// запрос.
|
|
||||||
//
|
|
||||||
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
|
|
||||||
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
|
|
||||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
|
||||||
// ложь — молча.
|
|
||||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
|
||||||
now := types.NowDateTime()
|
|
||||||
|
|
||||||
query := repo.app.DB().NewQuery(`
|
|
||||||
UPDATE {{` + JobsCollection + `}}
|
|
||||||
SET acquisition_id = {:acquisition_id},
|
|
||||||
acquire_time = {:now},
|
|
||||||
attempts = attempts + 1,
|
|
||||||
updated = {:now}
|
|
||||||
WHERE id = (
|
|
||||||
SELECT id FROM {{` + JobsCollection + `}}
|
|
||||||
WHERE state = {:state}
|
|
||||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
|
||||||
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
|
||||||
ORDER BY created, id
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
RETURNING ` + acquireColumns)
|
|
||||||
|
|
||||||
rotting, err := types.ParseDateTime(rottingTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Bind(dbx.Params{
|
|
||||||
"acquisition_id": acquisitionId,
|
|
||||||
"now": now.String(),
|
|
||||||
"state": state,
|
|
||||||
"rotting": rotting.String(),
|
|
||||||
})
|
|
||||||
|
|
||||||
var row acquiredRow
|
|
||||||
if err := query.One(&row); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return row.toJob(), nil
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
|
||||||
// путём, каким это делает сервис при старте.
|
|
||||||
func newTestApp(t *testing.T) core.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
app, err := New(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := app.ResetBootstrapState(); err != nil {
|
|
||||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
|
|
||||||
func newFile(t *testing.T, app core.App) *entity.File {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
repo := NewFileRepository(app)
|
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
|
||||||
|
|
||||||
file, err := repo.CreateLocal("sample.mp3", work)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
file := newFile(t, repo.app)
|
|
||||||
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
|
|
||||||
require.NoError(t, repo.Create(job))
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|
||||||
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
|
|
||||||
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
|
|
||||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
|
||||||
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
const racers = 3
|
|
||||||
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
mu sync.Mutex
|
|
||||||
got []*entity.TranscribeJob
|
|
||||||
notFound int
|
|
||||||
)
|
|
||||||
|
|
||||||
start := make(chan struct{})
|
|
||||||
for i := 0; i < racers; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(n int) {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start
|
|
||||||
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
var missing *contract.JobNotFoundError
|
|
||||||
if assert.ErrorAs(t, err, &missing) {
|
|
||||||
notFound++
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
got = append(got, acquired)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(start)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
|
|
||||||
assert.Equal(t, job.Id, got[0].Id)
|
|
||||||
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
|
|
||||||
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, first)
|
|
||||||
|
|
||||||
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
var missing *contract.JobNotFoundError
|
|
||||||
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
|
|
||||||
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
|
|
||||||
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
|
|
||||||
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
|
||||||
// собственные времена хранилища.
|
|
||||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
|
|
||||||
assert.Equal(t, job.Id, again.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пауза держит задачу от выдачи, пока не кончится.
|
|
||||||
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
delay := time.Now().Add(time.Hour)
|
|
||||||
job.DelayTime = &delay
|
|
||||||
require.NoError(t, repo.Save(job, ""))
|
|
||||||
|
|
||||||
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
var missing *contract.JobNotFoundError
|
|
||||||
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
|
|
||||||
// задаче, брошенной вместе с процессом.
|
|
||||||
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
for expected := 1; expected <= 3; expected++ {
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, acquired.Attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
|
|
||||||
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
|
|
||||||
// потерянное поле.
|
|
||||||
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
chatId := int64(4242)
|
|
||||||
replyId := 17
|
|
||||||
opId := "operation-id"
|
|
||||||
text := "расшифровка"
|
|
||||||
|
|
||||||
file := newFile(t, app)
|
|
||||||
|
|
||||||
job := &entity.TranscribeJob{
|
|
||||||
State: entity.StateTranscribe,
|
|
||||||
Source: entity.SourceTelegram,
|
|
||||||
FileID: &file.Id,
|
|
||||||
TgChatId: &chatId,
|
|
||||||
TgReplyMessageId: &replyId,
|
|
||||||
RecognitionOpID: &opId,
|
|
||||||
TranscriptionText: &text,
|
|
||||||
}
|
|
||||||
require.NoError(t, repo.Create(job))
|
|
||||||
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, job.Id, acquired.Id)
|
|
||||||
assert.Equal(t, entity.StateTranscribe, acquired.State)
|
|
||||||
assert.Equal(t, entity.SourceTelegram, acquired.Source)
|
|
||||||
require.NotNil(t, acquired.TgChatId)
|
|
||||||
assert.Equal(t, chatId, *acquired.TgChatId)
|
|
||||||
require.NotNil(t, acquired.TgReplyMessageId)
|
|
||||||
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
|
|
||||||
require.NotNil(t, acquired.RecognitionOpID)
|
|
||||||
assert.Equal(t, opId, *acquired.RecognitionOpID)
|
|
||||||
require.NotNil(t, acquired.TranscriptionText)
|
|
||||||
assert.Equal(t, text, *acquired.TranscriptionText)
|
|
||||||
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
|
|
||||||
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
|
||||||
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Задача досталась другому, пока шаг работал.
|
|
||||||
record, err := app.FindRecordById(JobsCollection, mine.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("acquisition_id", "someone-else")
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
mine.MoveToState(entity.StateConverted)
|
|
||||||
err = repo.Save(mine, "mine")
|
|
||||||
|
|
||||||
var lost *contract.LostAcquisitionError
|
|
||||||
require.ErrorAs(t, err, &lost)
|
|
||||||
|
|
||||||
// И состояние не поехало.
|
|
||||||
after, err := repo.GetByID(mine.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, entity.StateCreated, after.State)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
|
|
||||||
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
|
||||||
job.MoveToState(entity.StateConverted)
|
|
||||||
|
|
||||||
require.NoError(t, repo.Save(job, ""))
|
|
||||||
|
|
||||||
after, err := repo.GetByID(job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, entity.StateConverted, after.State)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
|
|
||||||
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
|
|
||||||
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
|
|
||||||
// захвату и умирает от первого же отказа, и не узнал бы об этом.
|
|
||||||
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
|
||||||
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acquired.AcquisitionID)
|
|
||||||
|
|
||||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("attempts", 5)
|
|
||||||
record.Set("state", entity.StateDead)
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
|
|
||||||
// запросом к записи, а не сохранением из кода.
|
|
||||||
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
|
|
||||||
|
|
||||||
after, err := repo.GetByID(job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
|
||||||
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
|
||||||
assert.Nil(t, after.DelayTime, "пауза снята")
|
|
||||||
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
|
|
||||||
|
|
||||||
// И ближайший захват задачу выдаёт.
|
|
||||||
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, job.Id, again.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обратная сторона того же правила, и она дороже: правила панели MUST не
|
|
||||||
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
|
|
||||||
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
|
|
||||||
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
|
|
||||||
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
job := newJob(t, repo, entity.StateConverted)
|
|
||||||
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Шаг ставит задержку опроса вместе со сменой состояния.
|
|
||||||
delay := time.Now().Add(10 * time.Second)
|
|
||||||
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
|
||||||
require.NoError(t, repo.Save(acquired, "holder"))
|
|
||||||
|
|
||||||
after, err := repo.GetByID(job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
|
||||||
|
|
||||||
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
|
|
||||||
// сколько раз мы пробовали.
|
|
||||||
after.Attempts = 6
|
|
||||||
after.Die("attempts exhausted: 6")
|
|
||||||
require.NoError(t, repo.Save(after, ""))
|
|
||||||
|
|
||||||
dead, err := repo.GetByID(job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, entity.StateDead, dead.State)
|
|
||||||
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
|
|
||||||
// API от имени владельца.
|
|
||||||
func patchRecord(t *testing.T, app core.App, recordID, body string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
owner := core.NewRecord(superusers)
|
|
||||||
owner.Set("email", "owner@example.com")
|
|
||||||
owner.Set("password", "ownerpassword123")
|
|
||||||
require.NoError(t, app.Save(owner))
|
|
||||||
|
|
||||||
token, err := owner.NewStaticAuthToken(time.Hour)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
router, err := apis.NewRouter(app)
|
|
||||||
require.NoError(t, err)
|
|
||||||
mux, err := router.BuildMux()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPatch,
|
|
||||||
"/api/collections/"+JobsCollection+"/records/"+recordID,
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", token)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
|
|
||||||
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
|
|
||||||
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
|
|
||||||
// отказа в панели.
|
|
||||||
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
BindPanelRules(app)
|
|
||||||
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
file := newFile(t, app)
|
|
||||||
chatId := int64(111)
|
|
||||||
job := &entity.TranscribeJob{
|
|
||||||
State: entity.StateCreated,
|
|
||||||
Source: entity.SourceTelegram,
|
|
||||||
FileID: &file.Id,
|
|
||||||
TgChatId: &chatId,
|
|
||||||
}
|
|
||||||
require.NoError(t, repo.Create(job))
|
|
||||||
|
|
||||||
// Шаг захватил задачу и работает.
|
|
||||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Владелец правит в панели поле, которого конвейер не касается.
|
|
||||||
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
|
|
||||||
|
|
||||||
// Шаг доработал и сохраняет свой снимок.
|
|
||||||
acquired.MoveToState(entity.StateConverted)
|
|
||||||
require.NoError(t, repo.Save(acquired, "holder"))
|
|
||||||
|
|
||||||
after, err := repo.GetByID(job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
|
||||||
require.NotNil(t, after.TgChatId)
|
|
||||||
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
|
||||||
|
// каталогом.
|
||||||
|
//
|
||||||
|
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
|
||||||
|
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
|
||||||
|
// читается как «репозитории поверх SQLite» без знания кода.
|
||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
|
||||||
|
// выбран: сборка бинарника остаётся без компилятора C.
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
|
||||||
|
const driverName = "sqlite"
|
||||||
|
|
||||||
|
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
|
||||||
|
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
|
||||||
|
// целиком, а не одним файлом.
|
||||||
|
const DatabaseFile = "transcriber.db"
|
||||||
|
|
||||||
|
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
|
||||||
|
// константой кода: крутят их при одном и том же отказе — «база занята» под
|
||||||
|
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
|
||||||
|
// пересборки образа.
|
||||||
|
type Settings struct {
|
||||||
|
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
|
||||||
|
BusyTimeoutMs int
|
||||||
|
// ReadConnections — сколько соединений держит читающий пул.
|
||||||
|
ReadConnections int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
|
||||||
|
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
|
||||||
|
// чтения означает пул без предела, то есть настройку, которой не управляют.
|
||||||
|
func (s Settings) Validate() error {
|
||||||
|
if s.BusyTimeoutMs <= 0 {
|
||||||
|
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
|
||||||
|
}
|
||||||
|
if s.ReadConnections <= 0 {
|
||||||
|
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
|
||||||
|
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
|
||||||
|
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
|
||||||
|
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
|
||||||
|
// перестал бы освобождать захват и писать причину остановки — то есть отмена
|
||||||
|
// ломала бы ровно ту уборку, ради которой она и делается.
|
||||||
|
//
|
||||||
|
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
|
||||||
|
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
|
||||||
|
|
||||||
|
// DB — база сервиса двумя пулами.
|
||||||
|
//
|
||||||
|
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
|
||||||
|
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
|
||||||
|
// получают отказ по занятости — на записи результата шага, то есть после
|
||||||
|
// оплаченной работы. Пул с одним соединением обращает их в очередь.
|
||||||
|
//
|
||||||
|
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
|
||||||
|
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||||
|
type DB struct {
|
||||||
|
// writer — единственное пишущее соединение. Через него идёт всякая
|
||||||
|
// операция, которая читает состояние и следом его пишет: транзакцию,
|
||||||
|
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
|
||||||
|
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
|
||||||
|
// лечит, ждать там нечего.
|
||||||
|
writer *sql.DB
|
||||||
|
// reader — пул чтения.
|
||||||
|
reader *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer отдаёт пишущее соединение.
|
||||||
|
func (db *DB) Writer() *sql.DB { return db.writer }
|
||||||
|
|
||||||
|
// Reader отдаёт читающий пул.
|
||||||
|
func (db *DB) Reader() *sql.DB { return db.reader }
|
||||||
|
|
||||||
|
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
|
||||||
|
//
|
||||||
|
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
|
||||||
|
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
|
||||||
|
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
|
||||||
|
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
|
||||||
|
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
|
||||||
|
// молча.
|
||||||
|
func Open(dataDir string, settings Settings) (*DB, error) {
|
||||||
|
if err := settings.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dataDir, DatabaseFile)
|
||||||
|
|
||||||
|
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
|
||||||
|
// операция, которая читает и следом пишет, иначе взяла бы читающую
|
||||||
|
// транзакцию и упёрлась бы в отказ при первой же записи.
|
||||||
|
writer, err := open(path, settings, "immediate")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
writer.SetMaxOpenConns(1)
|
||||||
|
writer.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
reader, err := open(path, settings, "deferred")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
|
||||||
|
}
|
||||||
|
reader.SetMaxOpenConns(settings.ReadConnections)
|
||||||
|
reader.SetMaxIdleConns(settings.ReadConnections)
|
||||||
|
|
||||||
|
db := &DB{writer: writer, reader: reader}
|
||||||
|
|
||||||
|
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
|
||||||
|
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
|
||||||
|
// то есть отказом каждого запроса вместо одной строки о причине.
|
||||||
|
if err := writer.PingContext(context.Background()); err != nil {
|
||||||
|
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open заводит один пул с общими настройками соединения.
|
||||||
|
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
|
||||||
|
query.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
query.Add("_pragma", "foreign_keys(1)")
|
||||||
|
query.Set("_txlock", txlock)
|
||||||
|
|
||||||
|
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
|
||||||
|
// закрытого пула отказом не считается.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
var errs []error
|
||||||
|
if db.reader != nil {
|
||||||
|
if err := db.reader.Close(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
|
||||||
|
}
|
||||||
|
db.reader = nil
|
||||||
|
}
|
||||||
|
if db.writer != nil {
|
||||||
|
if err := db.writer.Close(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
|
||||||
|
}
|
||||||
|
db.writer = nil
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
|
||||||
|
// конфига.
|
||||||
|
func testSettings() Settings {
|
||||||
|
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
|
||||||
|
// путём, каким это делает сервис при старте.
|
||||||
|
func newTestDB(t *testing.T) (*DB, *Store, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := Open(dir, testSettings())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Logf("не удалось закрыть базу: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||||
|
|
||||||
|
return db, NewStore(dir), dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||||
|
func newOwner(t *testing.T, db *DB) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return account.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
|
||||||
|
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
|
||||||
|
// один раз после открытия, настроил бы одно соединение из многих.
|
||||||
|
func TestSettingsApplyToEveryConnection(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
var mode string
|
||||||
|
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
|
||||||
|
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
|
||||||
|
|
||||||
|
var busy int
|
||||||
|
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
|
||||||
|
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
|
||||||
|
|
||||||
|
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
|
||||||
|
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
|
||||||
|
// поломка, ради которой настройка уехала в строку подключения.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
answers := make([]int, testSettings().ReadConnections)
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := range answers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
conn, err := db.Reader().Conn(context.Background())
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { assert.NoError(t, conn.Close()) }()
|
||||||
|
|
||||||
|
assert.NoError(t,
|
||||||
|
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
|
||||||
|
// Соединение придерживается, пока спрашивают остальные: иначе пул
|
||||||
|
// раздал бы всем одно и то же и правило проверило бы одну настройку
|
||||||
|
// вместо четырёх.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, answer := range answers {
|
||||||
|
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
|
||||||
|
// несуществующим владельцем отвергается обоими пулами.
|
||||||
|
now := clock.Now().Format(timeLayout)
|
||||||
|
insert := `INSERT INTO audio_records
|
||||||
|
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(), insert,
|
||||||
|
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
|
||||||
|
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
|
||||||
|
|
||||||
|
_, err = db.Reader().ExecContext(context.Background(), insert,
|
||||||
|
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
|
||||||
|
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
|
||||||
|
func TestSettingsAreValidated(t *testing.T) {
|
||||||
|
for name, settings := range map[string]Settings{
|
||||||
|
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
|
||||||
|
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
|
||||||
|
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
|
||||||
|
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := Open(t.TempDir(), settings)
|
||||||
|
assert.Error(t, err, "старт на негодном числе прошёл молча")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
|
||||||
|
// записей не теряет.
|
||||||
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := Open(dir, testSettings())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
|
||||||
|
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||||
|
|
||||||
|
var login string
|
||||||
|
require.NoError(t, db.Reader().
|
||||||
|
QueryRowContext(context.Background(),
|
||||||
|
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
|
||||||
|
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
|
||||||
|
|
||||||
|
var applied int
|
||||||
|
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
|
||||||
|
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
|
||||||
|
// освобождения, а не применяет шаги параллельно.
|
||||||
|
//
|
||||||
|
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
|
||||||
|
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
|
||||||
|
// именно его.
|
||||||
|
func TestMigrationLockSerializesRuns(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
inside int
|
||||||
|
overlap bool
|
||||||
|
)
|
||||||
|
|
||||||
|
hold := func() error {
|
||||||
|
mu.Lock()
|
||||||
|
inside++
|
||||||
|
if inside > 1 {
|
||||||
|
overlap = true
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
inside--
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 3 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
assert.NoError(t, withMigrationLock(dir, hold))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
|
||||||
|
// схеме, отвечал бы отказом на каждый запрос.
|
||||||
|
func TestMigrateFailsLoudly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := Open(dir, testSettings())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
|
||||||
|
// применяется.
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
require.Error(t, err, "отказ шага прошёл молча")
|
||||||
|
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
|
||||||
|
|
||||||
|
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
|
||||||
|
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
|
||||||
|
// состояние — то самое, из-за которого следующий запуск применил бы шаг
|
||||||
|
// второй раз и упал бы на заведённой таблице.
|
||||||
|
var version int
|
||||||
|
err = db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
|
||||||
|
if err == nil {
|
||||||
|
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables int
|
||||||
|
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
|
||||||
|
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
|
||||||
|
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
|
||||||
|
// прошла бы молча.
|
||||||
|
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
tables := []string{
|
||||||
|
"users", "files", "topics", "audio_records",
|
||||||
|
"texts", "structures", "recognitions", "record_events",
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := 0
|
||||||
|
for _, table := range tables {
|
||||||
|
rows, err := db.Reader().QueryContext(context.Background(),
|
||||||
|
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
columnType string
|
||||||
|
dflt any
|
||||||
|
)
|
||||||
|
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
|
||||||
|
|
||||||
|
if !isTimeColumn(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
|
||||||
|
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
|
||||||
|
}
|
||||||
|
require.NoError(t, rows.Err())
|
||||||
|
closeRows(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
|
||||||
|
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
|
||||||
|
func closeRows(t *testing.T, rows *sql.Rows) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, rows.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeColumn(name string) bool {
|
||||||
|
return strings.HasSuffix(name, "_at") || name == "delay_time"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
|
||||||
|
// отбор захвата одинаково: вид времени в схеме один.
|
||||||
|
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
|
||||||
|
byService := &entity.AudioRecord{
|
||||||
|
Id: ident.New(),
|
||||||
|
OwnerID: owner,
|
||||||
|
State: entity.StateUploaded,
|
||||||
|
StateEnteredAt: clock.Now(),
|
||||||
|
}
|
||||||
|
require.NoError(t, records.Create(byService))
|
||||||
|
|
||||||
|
byHand := ident.New()
|
||||||
|
now := clock.Now().Format(timeLayout)
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(),
|
||||||
|
`INSERT INTO audio_records
|
||||||
|
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||||
|
byHand, owner, entity.StateUploaded, now, now, now,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
acquired := map[string]bool{}
|
||||||
|
for range 2 {
|
||||||
|
got, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
require.NoError(t, err)
|
||||||
|
acquired[got.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
|
||||||
|
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
|
||||||
|
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
|
||||||
|
func TestHotQueriesUseIndexes(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
acquire := explain(t, db, `
|
||||||
|
SELECT id FROM audio_records
|
||||||
|
WHERE state IN (?, ?)
|
||||||
|
AND halted_at IS NULL
|
||||||
|
AND (delay_time IS NULL OR delay_time < ?)
|
||||||
|
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
|
||||||
|
ORDER BY created_at, id
|
||||||
|
LIMIT 1`,
|
||||||
|
entity.StateUploaded, entity.StateNormalized, "now", "now")
|
||||||
|
|
||||||
|
list := explain(t, db, `
|
||||||
|
SELECT id FROM audio_records
|
||||||
|
WHERE owner_id = ?
|
||||||
|
AND (created_at < ? OR (created_at = ? AND id < ?))
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 31`,
|
||||||
|
"owner", "now", "now", "id")
|
||||||
|
|
||||||
|
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
|
||||||
|
assert.NotContains(t, plan, "SCAN audio_records",
|
||||||
|
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
|
||||||
|
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
|
||||||
|
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func explain(t *testing.T, db *DB, query string, args ...any) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, rows.Close()) }()
|
||||||
|
|
||||||
|
var plan strings.Builder
|
||||||
|
for rows.Next() {
|
||||||
|
var id, parent, notUsed int
|
||||||
|
var detail string
|
||||||
|
require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail))
|
||||||
|
plan.WriteString(detail)
|
||||||
|
plan.WriteString("; ")
|
||||||
|
}
|
||||||
|
require.NoError(t, rows.Err())
|
||||||
|
|
||||||
|
return plan.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
|
||||||
|
// даёт паники.
|
||||||
|
func TestCloseIsIdempotent(t *testing.T) {
|
||||||
|
db, err := Open(t.TempDir(), testSettings())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
assert.NoError(t, db.Close(), "повторное закрытие отказало")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
|
||||||
|
// файла под рабочим именем, ни временного имени в подкаталоге записи.
|
||||||
|
func TestStorePutIsAtomic(t *testing.T) {
|
||||||
|
_, store, dir := newTestDB(t)
|
||||||
|
|
||||||
|
recordID := ident.New()
|
||||||
|
|
||||||
|
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
|
||||||
|
require.Error(t, err, "отказ источника прошёл молча")
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
|
||||||
|
|
||||||
|
temporary, err := store.HasTemporary(recordID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
|
||||||
|
}
|
||||||
|
|
||||||
|
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
|
||||||
|
// закрывший соединение на середине.
|
||||||
|
type brokenReader struct {
|
||||||
|
sent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *brokenReader) Read(p []byte) (int, error) {
|
||||||
|
if !r.sent {
|
||||||
|
r.sent = true
|
||||||
|
copy(p, strings.Repeat("a", min(len(p), 64)))
|
||||||
|
return min(len(p), 64), nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("источник оборвался")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ укладки несёт причину и не несёт пути.
|
||||||
|
//
|
||||||
|
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
|
||||||
|
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
|
||||||
|
// каталога требуют трёх разных действий, а отказ укладки — единственная
|
||||||
|
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
|
||||||
|
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
|
||||||
|
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
|
||||||
|
_, store, dir := newTestDB(t)
|
||||||
|
|
||||||
|
// noPath судит вторую половину: ни каталога данных, ни временной приставки
|
||||||
|
// в цепочке отказа быть не должно.
|
||||||
|
noPath := func(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
|
||||||
|
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("места на диске нет", func(t *testing.T) {
|
||||||
|
recordID := ident.New()
|
||||||
|
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
|
||||||
|
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
|
||||||
|
})
|
||||||
|
noPath(t, err)
|
||||||
|
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
|
||||||
|
recordID := ident.New()
|
||||||
|
recordDir := filepath.Join(dir, recordsDir, recordID)
|
||||||
|
require.NoError(t, os.MkdirAll(recordDir, 0o750))
|
||||||
|
require.NoError(t, os.Chmod(recordDir, 0o500))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chmod(recordDir, 0o750); err != nil {
|
||||||
|
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
|
||||||
|
noPath(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
|
||||||
|
recordID := ident.New()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
|
||||||
|
|
||||||
|
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
|
||||||
|
noPath(t, err)
|
||||||
|
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("копии нет", func(t *testing.T) {
|
||||||
|
_, err := store.Open(ident.New(), "voice.mp3")
|
||||||
|
noPath(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
|
||||||
|
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
|
||||||
|
// рабочей копии, и его отказ приходит ровно этой формой.
|
||||||
|
type diskFullReader struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *diskFullReader) Read([]byte) (int, error) {
|
||||||
|
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
|
||||||
|
// где лежит что-то из них, нет.
|
||||||
|
func TestCopiesOfRecordLiveTogether(t *testing.T) {
|
||||||
|
db, store, dir := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
files := NewFileRepository(db, store)
|
||||||
|
recordID := ident.New()
|
||||||
|
|
||||||
|
for _, name := range []string{"original.mp3", "normalized.ogg"} {
|
||||||
|
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, work.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
|
||||||
|
|
||||||
|
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, records, 1, "второго места для копий записи не появляется")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
|
||||||
|
// не файл целиком.
|
||||||
|
func TestOpenGivesSeekableStream(t *testing.T) {
|
||||||
|
db, store, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
files := NewFileRepository(db, store)
|
||||||
|
recordID := ident.New()
|
||||||
|
|
||||||
|
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, work.Close())
|
||||||
|
|
||||||
|
reader, err := files.Open(file.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, reader.Close()) }()
|
||||||
|
|
||||||
|
_, err = reader.Seek(4, io.SeekStart)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
slice := make([]byte, 3)
|
||||||
|
_, err = io.ReadFull(reader, slice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "456", string(slice))
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
|
||||||
|
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
|
||||||
|
// место.
|
||||||
|
type workFile struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *workFile) Path() string { return w.path }
|
||||||
|
|
||||||
|
func (w *workFile) Size() (int64, error) {
|
||||||
|
info, err := os.Stat(w.path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to stat work file: %w", err)
|
||||||
|
}
|
||||||
|
return info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
|
||||||
|
// до его создания, и повторный Close тоже законен.
|
||||||
|
func (w *workFile) Close() error {
|
||||||
|
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove work file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
|
||||||
|
type FileRepository struct {
|
||||||
|
db *DB
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileRepository(db *DB, store *Store) *FileRepository {
|
||||||
|
return &FileRepository{db: db, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
|
||||||
|
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
|
||||||
|
func newWorkFile(ext string) (*workFile, error) {
|
||||||
|
f, err := os.CreateTemp("", "transcriber-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create work file: %w", err)
|
||||||
|
}
|
||||||
|
path := f.Name()
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil, fmt.Errorf("failed to close work file: %w", err)
|
||||||
|
}
|
||||||
|
return &workFile{path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
|
||||||
|
return newWorkFile(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
|
||||||
|
work, err := newWorkFile(ext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeTo(work.path, content); err != nil {
|
||||||
|
// Отказ уборки не подменяет отказ записи, но и не теряется.
|
||||||
|
return nil, errors.Join(err, work.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
return work, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||||
|
file, err := repo.GetByID(fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
work, err := newWorkFile(filepath.Ext(file.FileName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := repo.store.Open(file.RecordID, file.FileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Join(err, work.Close())
|
||||||
|
}
|
||||||
|
defer func() { _ = src.Close() }()
|
||||||
|
|
||||||
|
if err := writeTo(work.path, src); err != nil {
|
||||||
|
return nil, errors.Join(err, work.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
return work, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
|
||||||
|
//
|
||||||
|
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
|
||||||
|
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
|
||||||
|
// файл, которого ещё нет или который короче принятого.
|
||||||
|
//
|
||||||
|
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
|
||||||
|
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
|
||||||
|
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
|
||||||
|
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
|
||||||
|
//
|
||||||
|
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
|
||||||
|
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
|
||||||
|
func (repo *FileRepository) Create(
|
||||||
|
recordID, name string,
|
||||||
|
work contract.WorkFile,
|
||||||
|
meta contract.FileMeta,
|
||||||
|
ownerID string,
|
||||||
|
) (*entity.File, error) {
|
||||||
|
source, err := os.Open(work.Path())
|
||||||
|
if err != nil {
|
||||||
|
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
|
||||||
|
// аудио, а отказ кончается в журнале.
|
||||||
|
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
size, putErr := repo.store.Put(recordID, name, source)
|
||||||
|
closeErr := source.Close()
|
||||||
|
if err := errors.Join(putErr, closeErr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := &entity.File{
|
||||||
|
Id: ident.New(),
|
||||||
|
RecordID: recordID,
|
||||||
|
FileName: name,
|
||||||
|
Size: size,
|
||||||
|
Format: meta.Format,
|
||||||
|
DurationMs: meta.DurationMs,
|
||||||
|
CreatedAt: clock.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := insertSQL("files", map[string]any{
|
||||||
|
"id": file.Id,
|
||||||
|
"owner_id": ownerID,
|
||||||
|
"record_id": file.RecordID,
|
||||||
|
"file_name": file.FileName,
|
||||||
|
"size_bytes": file.Size,
|
||||||
|
"format": file.Format,
|
||||||
|
"duration_ms": file.DurationMs,
|
||||||
|
"created_at": formatTime(file.CreatedAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
|
||||||
|
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
|
||||||
|
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
|
||||||
|
return nil, errors.Join(
|
||||||
|
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
|
||||||
|
repo.store.Remove(recordID, name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||||
|
file := &entity.File{}
|
||||||
|
var (
|
||||||
|
createdAt string
|
||||||
|
recordID string
|
||||||
|
fileName string
|
||||||
|
size int64
|
||||||
|
format string
|
||||||
|
durationMs int64
|
||||||
|
)
|
||||||
|
|
||||||
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||||
|
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
|
||||||
|
FROM files WHERE id = ?`, id,
|
||||||
|
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("file %s is not found", id)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Id = id
|
||||||
|
file.RecordID = recordID
|
||||||
|
file.FileName = fileName
|
||||||
|
file.Size = size
|
||||||
|
file.Format = format
|
||||||
|
file.DurationMs = durationMs
|
||||||
|
file.CreatedAt = requiredTimeOf(createdAt)
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
|
||||||
|
// диапазону читает кусок, а не файл целиком.
|
||||||
|
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
|
||||||
|
file, err := repo.GetByID(fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return repo.store.Open(file.RecordID, file.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTo переливает содержимое в файл потоком. В память запись целиком не
|
||||||
|
// читается: расчётный потолок — шесть часов.
|
||||||
|
func writeTo(path string, content io.Reader) error {
|
||||||
|
dst, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open work file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, content); err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
|
return fmt.Errorf("failed to write work file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dst.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close work file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepository — учётные записи сервиса.
|
||||||
|
type UserRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
|
||||||
|
// её.
|
||||||
|
//
|
||||||
|
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
||||||
|
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
|
||||||
|
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
|
||||||
|
// либо вытаскивать задним числом.
|
||||||
|
//
|
||||||
|
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
||||||
|
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
||||||
|
// его работы.
|
||||||
|
//
|
||||||
|
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
|
||||||
|
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
|
||||||
|
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
|
||||||
|
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
|
||||||
|
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
|
||||||
|
// учётная запись один раз за жизнь человека.
|
||||||
|
//
|
||||||
|
// **Окно между двумя соединениями закрыто повторным поиском внутри
|
||||||
|
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
|
||||||
|
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
|
||||||
|
// уникальность ключа держит схема — не порядок обращений.
|
||||||
|
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
|
||||||
|
login, ok := entity.AcceptProviderLogin(identity.Login)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, contract.ErrLoginNotAcceptable
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := findUserByLogin(repo.db.Reader(), login)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if account != nil {
|
||||||
|
return account, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// Повторный поиск закрывает окно между читающим пулом и пишущей
|
||||||
|
// транзакцией: пока её ждали, запись мог завести сосед.
|
||||||
|
account, err = findUserByLogin(tx, login)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if account != nil {
|
||||||
|
return account, false, commitAccount(tx, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entity.AcceptDisplayName(identity.Name)
|
||||||
|
email, _ := entity.AcceptEmail(identity.Email)
|
||||||
|
|
||||||
|
account, err = insertUser(tx, login, name, email)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return account, true, commitAccount(tx, account)
|
||||||
|
case !isUniqueViolation(err):
|
||||||
|
return nil, false, fmt.Errorf("failed to create user account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Два отказа уникальности различаются, и исход у них разный**, а какая
|
||||||
|
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
|
||||||
|
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
|
||||||
|
// просто взять заведённую соседом запись.
|
||||||
|
account, err = findUserByLogin(tx, login)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if account != nil {
|
||||||
|
return account, false, commitAccount(tx, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
|
||||||
|
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
|
||||||
|
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
|
||||||
|
// этого разреза второй человек с общим адресом не завёлся бы никогда —
|
||||||
|
// повторный поиск по логину снова ничего не находит.
|
||||||
|
account, err = insertUser(tx, login, name, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return account, true, commitAccount(tx, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
|
||||||
|
id := ident.New()
|
||||||
|
now := formatTime(clock.Now())
|
||||||
|
|
||||||
|
_, err := tx.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, login, name, email, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.UserAccount{ID: id, Name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
|
||||||
|
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
|
||||||
|
// запросом: второй его копией они разошлись бы молча.
|
||||||
|
type rowQuerier interface {
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
|
||||||
|
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
|
||||||
|
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
|
||||||
|
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
|
||||||
|
account := &contract.UserAccount{}
|
||||||
|
err := q.QueryRowContext(context.Background(),
|
||||||
|
"SELECT id, name FROM users WHERE provider_login = ?", login,
|
||||||
|
).Scan(&account.ID, &account.Name)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return account, nil
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
|
||||||
|
// данных рядом с базой: замок принадлежит каталогу, а не машине.
|
||||||
|
const migrationLockFile = "migrate.lock"
|
||||||
|
|
||||||
|
// Migrate приводит схему к последнему шагу.
|
||||||
|
//
|
||||||
|
// # Порядок
|
||||||
|
//
|
||||||
|
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
|
||||||
|
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
|
||||||
|
// запрос и на каждый прогон воркера — вместо одной строки о причине их
|
||||||
|
// становятся сотни, и первопричина в них теряется.
|
||||||
|
//
|
||||||
|
// # Чем держится неделимость
|
||||||
|
//
|
||||||
|
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
||||||
|
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
|
||||||
|
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
|
||||||
|
//
|
||||||
|
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
|
||||||
|
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
|
||||||
|
// дают отказ сбора, а не молчаливый выбор одного.
|
||||||
|
//
|
||||||
|
// # Почему замок наш
|
||||||
|
//
|
||||||
|
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
|
||||||
|
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
|
||||||
|
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
|
||||||
|
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
|
||||||
|
// замка, который надо чистить руками, не остаётся.
|
||||||
|
//
|
||||||
|
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
|
||||||
|
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
|
||||||
|
// повышает.
|
||||||
|
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := goose.NewProvider(
|
||||||
|
goose.DialectSQLite3,
|
||||||
|
db.Writer(),
|
||||||
|
nil,
|
||||||
|
goose.WithGoMigrations(migrations.All()...),
|
||||||
|
// Глобальный список библиотеки не читается: перечень шагов приходит
|
||||||
|
// доводом, и два провайдера в одном процессе за общее состояние не
|
||||||
|
// спорят.
|
||||||
|
goose.WithDisableGlobalRegistry(true),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare schema migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withMigrationLock(dataDir, func() error {
|
||||||
|
results, err := provider.Up(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
|
||||||
|
// владелец сервиса по ней находит файл шага.
|
||||||
|
return fmt.Errorf("failed to apply schema migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
logger.Info("Schema migration applied",
|
||||||
|
"migration_version", result.Source.Version,
|
||||||
|
"duration_ms", result.Duration.Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
|
||||||
|
//
|
||||||
|
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
|
||||||
|
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
|
||||||
|
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
|
||||||
|
func withMigrationLock(dataDir string, run func() error) error {
|
||||||
|
path := filepath.Join(dataDir, migrationLockFile)
|
||||||
|
|
||||||
|
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open migration lock: %w", err)
|
||||||
|
}
|
||||||
|
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
|
||||||
|
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
|
||||||
|
// снимает ядро тем же движением — просроченного замка, который надо чистить
|
||||||
|
// руками, не остаётся.
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||||
|
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// up202608220002 заводит схему сервиса целиком.
|
||||||
|
//
|
||||||
|
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
|
||||||
|
// ним — разовое снятие инварианта «применённая миграция не переписывается»
|
||||||
|
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
|
||||||
|
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
|
||||||
|
// применённого своей таблицей, которой отметки прежнего каталога не годятся
|
||||||
|
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
|
||||||
|
// инвариант как всякий прежний.
|
||||||
|
//
|
||||||
|
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
|
||||||
|
// ссылается, и только потом обратные ссылки записи на её приложения.
|
||||||
|
//
|
||||||
|
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
|
||||||
|
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
|
||||||
|
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
|
||||||
|
// вставка, забывшая проставить время, при умолчании прошла бы молча.
|
||||||
|
//
|
||||||
|
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
|
||||||
|
// и вид текста были закрыты схемой, потому что панель владельца правила запись
|
||||||
|
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
|
||||||
|
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
|
||||||
|
// стоило бы нового шага схемы — без покупателя.
|
||||||
|
func up202608220002(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
for _, statement := range initStatements() {
|
||||||
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply initial schema: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
|
||||||
|
// приложения ссылаются на запись, запись — на учётную запись.
|
||||||
|
func down202608220002(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
tables := []string{
|
||||||
|
"record_events",
|
||||||
|
"recognitions",
|
||||||
|
"structures",
|
||||||
|
"texts",
|
||||||
|
"record_topics",
|
||||||
|
"audio_records",
|
||||||
|
"topics",
|
||||||
|
"files",
|
||||||
|
"users",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initStatements — шаг по одному оператору на элемент.
|
||||||
|
//
|
||||||
|
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
|
||||||
|
// запятой, и разбиение общей строки резало бы его пополам.
|
||||||
|
func initStatements() []string {
|
||||||
|
return []string{
|
||||||
|
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
|
||||||
|
// доверенного источника, и по нему запись находится при каждом
|
||||||
|
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
|
||||||
|
// и первое обращение с чужим адресом досталось бы чужой записи.
|
||||||
|
`CREATE TABLE users (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
provider_login TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
|
||||||
|
// Уникальность почты частичная: пустая почта законна и не спорит с
|
||||||
|
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
|
||||||
|
// схемой — по этому отказу заведение переходит на ветвь «запись без
|
||||||
|
// почты», а не отдаёт чужую учётную запись.
|
||||||
|
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
|
||||||
|
|
||||||
|
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
|
||||||
|
// через запись: файл переживает свою запись — шаг заводит его до
|
||||||
|
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
|
||||||
|
//
|
||||||
|
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
|
||||||
|
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
|
||||||
|
// самой записи, и обязательная связь отвергала бы первую же принятую
|
||||||
|
// запись.
|
||||||
|
`CREATE TABLE files (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||||
|
record_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
format TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX idx_files_owner ON files (owner_id)`,
|
||||||
|
|
||||||
|
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
|
||||||
|
// человека нужен целиком перед каждым обращением к модели, а собрать его
|
||||||
|
// из наборов строк можно только перебором всех его записей.
|
||||||
|
`CREATE TABLE topics (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
|
||||||
|
|
||||||
|
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
|
||||||
|
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
|
||||||
|
// чтение очереди её не тянет.
|
||||||
|
//
|
||||||
|
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
|
||||||
|
// записи не бывает, и держит это схема, а не проверка вызывающего.
|
||||||
|
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
|
||||||
|
// записей непустые, — поэтому ничью запись отвергает та же связь.
|
||||||
|
//
|
||||||
|
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
|
||||||
|
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
|
||||||
|
// метаданные которой прочитать не удалось, отвергается отказом и не
|
||||||
|
// заводится вовсе. Решение владельца 2026-08-15.
|
||||||
|
`CREATE TABLE audio_records (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||||
|
title TEXT,
|
||||||
|
brief TEXT,
|
||||||
|
original_filename TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
state_entered_at TEXT NOT NULL,
|
||||||
|
halted_at TEXT,
|
||||||
|
halt_reason TEXT,
|
||||||
|
error_text TEXT,
|
||||||
|
acquisition_id TEXT,
|
||||||
|
acquire_expires_at TEXT,
|
||||||
|
delay_time TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
original_file_id TEXT REFERENCES files (id),
|
||||||
|
normalized_file_id TEXT REFERENCES files (id),
|
||||||
|
transcript_text_id TEXT,
|
||||||
|
literary_text_id TEXT,
|
||||||
|
structure_id TEXT,
|
||||||
|
recognition_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
|
||||||
|
// Индекс заводится здесь, а не потом: применённый шаг схемы не
|
||||||
|
// переписывается, и добавление индекса стоило бы отдельного шага.
|
||||||
|
`CREATE INDEX idx_audio_records_acquire
|
||||||
|
ON audio_records (state, halted_at, created_at, id)`,
|
||||||
|
// Страница списка сужается владельцем и режется полным ключом
|
||||||
|
// сортировки — парой «время заведения и ключ записи».
|
||||||
|
`CREATE INDEX idx_audio_records_owner_page
|
||||||
|
ON audio_records (owner_id, created_at, id)`,
|
||||||
|
|
||||||
|
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
|
||||||
|
// набора строк в колонке нет ни связи, ни потолка.
|
||||||
|
`CREATE TABLE record_topics (
|
||||||
|
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||||
|
topic_id TEXT NOT NULL REFERENCES topics (id),
|
||||||
|
PRIMARY KEY (record_id, topic_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
|
||||||
|
// Потолок числа тем держит схема: без него часовой разговор даёт два
|
||||||
|
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
|
||||||
|
// то же самое, которое сервис объявляет приложению.
|
||||||
|
`CREATE TRIGGER trg_record_topics_limit
|
||||||
|
BEFORE INSERT ON record_topics
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'record has too many topics')
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
|
||||||
|
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
|
||||||
|
END`,
|
||||||
|
|
||||||
|
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
|
||||||
|
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
|
||||||
|
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||||
|
`CREATE TABLE texts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
contents TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
|
||||||
|
|
||||||
|
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
|
||||||
|
// ответа изменится раньше, чем архив пересчитают.
|
||||||
|
`CREATE TABLE structures (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
contents TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
|
||||||
|
|
||||||
|
// Попытка распознавания у внешнего провайдера.
|
||||||
|
//
|
||||||
|
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
|
||||||
|
// здесь стоит только его имя: шаг опроса читает эту строку раз в
|
||||||
|
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
|
||||||
|
// ехал бы в память при каждом опросе.
|
||||||
|
`CREATE TABLE recognitions (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
external_id TEXT NOT NULL DEFAULT '',
|
||||||
|
source_uri TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_file TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
|
||||||
|
|
||||||
|
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
|
||||||
|
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
|
||||||
|
// секрете, и две колонки с этим именем сделали бы инвариант
|
||||||
|
// двусмысленным.
|
||||||
|
`CREATE TABLE record_events (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
step TEXT NOT NULL DEFAULT '',
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
outcome_text TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Package migrations — шаги схемы базы.
|
||||||
|
//
|
||||||
|
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
|
||||||
|
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
|
||||||
|
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
|
||||||
|
//
|
||||||
|
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
|
||||||
|
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
|
||||||
|
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
|
||||||
|
// `[docs]`), а префикс наводится только на каталог.
|
||||||
|
//
|
||||||
|
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
|
||||||
|
// заводится в точке входа и получает этот перечень доводом, поэтому два
|
||||||
|
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
|
||||||
|
// состояние.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All — шаги схемы в порядке версий.
|
||||||
|
//
|
||||||
|
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
|
||||||
|
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
|
||||||
|
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
|
||||||
|
// накатывается вовсе.
|
||||||
|
func All() []*goose.Migration {
|
||||||
|
return []*goose.Migration{
|
||||||
|
goose.NewGoMigration(
|
||||||
|
202608220002,
|
||||||
|
&goose.GoFunc{RunTx: up202608220002},
|
||||||
|
&goose.GoFunc{RunTx: down202608220002},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
|
||||||
|
// провайдера. Имя задаёт сервис, как и у копий аудио.
|
||||||
|
const payloadSuffix = ".payload"
|
||||||
|
|
||||||
|
type RecognitionRepository struct {
|
||||||
|
db *DB
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
|
||||||
|
return &RecognitionRepository{db: db, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create заводит строку попытки **до** обращения к провайдеру.
|
||||||
|
//
|
||||||
|
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
|
||||||
|
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
|
||||||
|
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
|
||||||
|
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
|
||||||
|
started := clock.Now()
|
||||||
|
if r.Id == "" {
|
||||||
|
r.Id = ident.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
now := formatTime(started)
|
||||||
|
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||||
|
`INSERT INTO recognitions
|
||||||
|
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.StartedAt = &started
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
||||||
|
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
|
||||||
|
// наружу не платит.
|
||||||
|
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
|
||||||
|
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||||
|
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
|
||||||
|
sourceURI, externalID, formatTime(clock.Now()), id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
|
||||||
|
// записи** и отмечает завершение попытки.
|
||||||
|
//
|
||||||
|
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
|
||||||
|
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
|
||||||
|
// опросе. Хранится он потому, что результат операции у провайдера не
|
||||||
|
// переспрашивается.
|
||||||
|
//
|
||||||
|
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
|
||||||
|
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
|
||||||
|
// сохранённое без возврата.
|
||||||
|
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
|
||||||
|
attempt, err := repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := id + payloadSuffix
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
|
||||||
|
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
|
||||||
|
// её идентификатором.
|
||||||
|
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finished := formatTime(clock.Now())
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_, err = repo.db.Writer().ExecContext(context.Background(),
|
||||||
|
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
name, finished, finished, id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = repo.db.Writer().ExecContext(context.Background(),
|
||||||
|
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
finished, finished, id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store provider payload of attempt %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
|
||||||
|
attempt := &entity.Recognition{Id: id}
|
||||||
|
var startedAt, finishedAt, payloadFile nullString
|
||||||
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||||
|
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
|
||||||
|
FROM recognitions WHERE id = ?`, id,
|
||||||
|
).Scan(
|
||||||
|
&attempt.RecordID, &attempt.Provider, &attempt.Model,
|
||||||
|
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
|
||||||
|
&startedAt, &finishedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.StartedAt = timeOf(startedAt.NullString)
|
||||||
|
attempt.FinishedAt = timeOf(finishedAt.NullString)
|
||||||
|
|
||||||
|
return attempt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
|
||||||
|
// нужен: шаг опроса читает строку попытки без него.
|
||||||
|
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
|
||||||
|
attempt, err := repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadFile string
|
||||||
|
if err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT payload_file FROM recognitions WHERE id = ?", id,
|
||||||
|
).Scan(&payloadFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if payloadFile == "" {
|
||||||
|
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := repo.store.Open(attempt.RecordID, payloadFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
|
||||||
|
// значение, читается в неё, а домену отдаётся указателем.
|
||||||
|
type nullString struct {
|
||||||
|
sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
|
||||||
|
// приходит целиком байтами, а укладка принимает поток.
|
||||||
|
func bytesReader(raw []byte) io.Reader {
|
||||||
|
return bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordEventRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecordEventRepository(db *DB) *RecordEventRepository {
|
||||||
|
return &RecordEventRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append пишет строку журнала событий записи.
|
||||||
|
//
|
||||||
|
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
|
||||||
|
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
|
||||||
|
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
|
||||||
|
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
|
||||||
|
// молча.
|
||||||
|
//
|
||||||
|
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
|
||||||
|
// наравне с журналом сервиса.
|
||||||
|
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
|
||||||
|
if event.Id == "" {
|
||||||
|
event.Id = ident.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||||
|
`INSERT INTO record_events
|
||||||
|
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
event.Id, event.RecordID, event.Origin, event.Step,
|
||||||
|
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
|
||||||
|
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
|
||||||
|
const defaultListLimit = 30
|
||||||
|
|
||||||
|
// List отдаёт страницу записей владельца, новыми сверху.
|
||||||
|
//
|
||||||
|
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
|
||||||
|
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
|
||||||
|
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
|
||||||
|
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
|
||||||
|
// элемент между ними не пришёл бы никогда — и оба раза молча.
|
||||||
|
//
|
||||||
|
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
|
||||||
|
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
|
||||||
|
// определён вовсе.
|
||||||
|
//
|
||||||
|
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
|
||||||
|
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
|
||||||
|
// колонками самой записи.
|
||||||
|
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
|
||||||
|
// Пустой владелец не совпадает ни с одной записью. Правило записано со
|
||||||
|
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
|
||||||
|
// пустую строку пропустила бы.
|
||||||
|
if q.OwnerID == "" {
|
||||||
|
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
|
||||||
|
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
|
||||||
|
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
|
||||||
|
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
|
||||||
|
// воркеров нет вовсе.
|
||||||
|
if q.Limit <= 0 {
|
||||||
|
q.Limit = defaultListLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions := []string{"owner_id = ?"}
|
||||||
|
args := []any{q.OwnerID}
|
||||||
|
|
||||||
|
state, stateArgs, err := stateCondition(q.Filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
conditions = append(conditions, state)
|
||||||
|
args = append(args, stateArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := repo.countRecords(conditions, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageConditions := conditions
|
||||||
|
pageArgs := args
|
||||||
|
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
||||||
|
// — строго меньше по идентификатору.
|
||||||
|
if q.Cursor != nil {
|
||||||
|
pageConditions = append(append([]string{}, conditions...),
|
||||||
|
"(created_at < ? OR (created_at = ? AND id < ?))")
|
||||||
|
cursorTime := formatTime(q.Cursor.CreatedAt)
|
||||||
|
pageArgs = append(append([]any{}, args...),
|
||||||
|
cursorTime, cursorTime, q.Cursor.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &recordRow{}
|
||||||
|
columns, targets := selectList(readRecordColumns(row), "")
|
||||||
|
|
||||||
|
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
||||||
|
// следующая страница» без второго запроса и без вычислений по общему числу,
|
||||||
|
// которое к этому моменту могло измениться.
|
||||||
|
query := "SELECT " + columns + " FROM " + recordsTable +
|
||||||
|
" WHERE " + strings.Join(pageConditions, " AND ") +
|
||||||
|
" ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||||
|
pageArgs = append(pageArgs, q.Limit+1)
|
||||||
|
|
||||||
|
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list audio records: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
items := []*entity.AudioRecord{}
|
||||||
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(targets...); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
|
||||||
|
}
|
||||||
|
items = append(items, rowToAudioRecord(row))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := &contract.RecordPage{TotalItems: total}
|
||||||
|
if len(items) > q.Limit {
|
||||||
|
last := items[q.Limit-1]
|
||||||
|
page.NextCursor = &contract.RecordCursor{
|
||||||
|
CreatedAt: last.CreatedAt,
|
||||||
|
ID: last.Id,
|
||||||
|
}
|
||||||
|
items = items[:q.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
ids = append(ids, item.Id)
|
||||||
|
}
|
||||||
|
topics, err := repo.topicsOf(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
item.TopicIDs = topics[item.Id]
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Items = items
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateCondition переводит состояние отбора в условие запроса.
|
||||||
|
//
|
||||||
|
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
||||||
|
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
||||||
|
// молча поменял бы состав всех трёх состояний.
|
||||||
|
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
|
||||||
|
if filter == nil {
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch *filter {
|
||||||
|
case entity.ListFilterHalted:
|
||||||
|
return "halted_at IS NOT NULL", nil, nil
|
||||||
|
case entity.ListFilterWorking:
|
||||||
|
condition, args := stateIn(entity.WorkingStages())
|
||||||
|
return "halted_at IS NULL AND " + condition, args, nil
|
||||||
|
case entity.ListFilterDone:
|
||||||
|
condition, args := stateIn(entity.TerminalStages())
|
||||||
|
return "halted_at IS NULL AND " + condition, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
|
||||||
|
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
|
||||||
|
// под именем отбора — и заметить это было бы нечем.
|
||||||
|
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateIn(stages []entity.Stage) (string, []any) {
|
||||||
|
names := entity.StageNames(stages)
|
||||||
|
args := make([]any, 0, len(names))
|
||||||
|
placeholders := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
args = append(args, name)
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
}
|
||||||
|
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
|
||||||
|
var total int
|
||||||
|
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count audio records: %w", err)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
|
||||||
|
// иначе стоила бы сотни обращений к базе.
|
||||||
|
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
|
||||||
|
out := map[string][]string{}
|
||||||
|
if len(recordIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, 0, len(recordIDs))
|
||||||
|
args := make([]any, 0, len(recordIDs))
|
||||||
|
for _, id := range recordIDs {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
|
||||||
|
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
|
||||||
|
|
||||||
|
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read record topics: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var recordID, topicID string
|
||||||
|
if err := rows.Scan(&recordID, &topicID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read a record topic: %w", err)
|
||||||
|
}
|
||||||
|
out[recordID] = append(out[recordID], topicID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read record topics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
|
||||||
|
// не по запросу на запись.
|
||||||
|
//
|
||||||
|
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
|
||||||
|
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
|
||||||
|
// чего контракт согласуется один раз.
|
||||||
|
//
|
||||||
|
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
|
||||||
|
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
|
||||||
|
// чужой темы, как только темы начнёт писать языковая модель.
|
||||||
|
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
if len(ids) == 0 || ownerID == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, 0, len(ids))
|
||||||
|
args := []any{ownerID}
|
||||||
|
for _, id := range ids {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
|
||||||
|
strings.Join(placeholders, ", ") + ")"
|
||||||
|
|
||||||
|
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name string
|
||||||
|
if err := rows.Scan(&id, &name); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read a topic: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = name
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordsTable — таблица аудиозаписей.
|
||||||
|
const recordsTable = "audio_records"
|
||||||
|
|
||||||
|
type AudioRecordRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
|
||||||
|
return &AudioRecordRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create заводит аудиозапись.
|
||||||
|
//
|
||||||
|
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
|
||||||
|
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
|
||||||
|
// идентификатором. Пустой заполняется единой точкой выдачи.
|
||||||
|
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||||
|
if r.Id == "" {
|
||||||
|
r.Id = ident.New()
|
||||||
|
}
|
||||||
|
now := clock.Now()
|
||||||
|
if r.CreatedAt.IsZero() {
|
||||||
|
r.CreatedAt = now
|
||||||
|
}
|
||||||
|
r.UpdatedAt = now
|
||||||
|
if r.StateEnteredAt.IsZero() {
|
||||||
|
r.StateEnteredAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := insertSQL(recordsTable, writeRecord(r))
|
||||||
|
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
|
||||||
|
return fmt.Errorf("failed to insert audio record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save сохраняет запись, захват которой держит holder.
|
||||||
|
//
|
||||||
|
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
|
||||||
|
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
|
||||||
|
// именно значение, а не занятость записи — захват, перевыданный другому по
|
||||||
|
// протуханию срока или после того, как человек вернул запись в работу, обязан
|
||||||
|
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
|
||||||
|
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
|
||||||
|
//
|
||||||
|
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
|
||||||
|
// получают признак захвата от FindAndAcquire.
|
||||||
|
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
||||||
|
r.UpdatedAt = clock.Now()
|
||||||
|
|
||||||
|
where := "id = :id"
|
||||||
|
whereArgs := []any{sql.Named("id", r.Id)}
|
||||||
|
if holder != "" {
|
||||||
|
where += " AND acquisition_id = :holder"
|
||||||
|
whereArgs = append(whereArgs, sql.Named("holder", holder))
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
|
||||||
|
|
||||||
|
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update audio record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
|
||||||
|
}
|
||||||
|
if affected > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строк не тронуто по одной из двух причин, и различить их можно только
|
||||||
|
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
|
||||||
|
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
|
||||||
|
if _, err := repo.Get(r.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.LostAcquisitionError{JobID: r.Id}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID отдаёт запись, только если её владелец — ownerID.
|
||||||
|
//
|
||||||
|
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
|
||||||
|
// иначе перебирается список заведённых записей, а идентификатор записи и есть
|
||||||
|
// то, что разграничение прячет.
|
||||||
|
//
|
||||||
|
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
|
||||||
|
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
|
||||||
|
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
|
||||||
|
// учётной записи получил бы выборку вместо отказа.
|
||||||
|
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||||
|
if ownerID == "" {
|
||||||
|
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
|
||||||
|
// владельцем не сужается.
|
||||||
|
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
|
||||||
|
return repo.read("id = ?", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read читает одну запись по условию.
|
||||||
|
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
|
||||||
|
row := &recordRow{}
|
||||||
|
columns, targets := selectList(readRecordColumns(row), "")
|
||||||
|
|
||||||
|
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
|
||||||
|
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
|
||||||
|
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
|
||||||
|
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
|
||||||
|
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
|
||||||
|
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
|
||||||
|
// настоящей аварии хранилища.
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get audio record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := rowToAudioRecord(row)
|
||||||
|
|
||||||
|
topics, err := repo.topicsOf([]string{record.Id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record.TopicIDs = topics[record.Id]
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
|
||||||
|
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
|
||||||
|
// возвратом.
|
||||||
|
//
|
||||||
|
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
|
||||||
|
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
|
||||||
|
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
|
||||||
|
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
|
||||||
|
//
|
||||||
|
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
|
||||||
|
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
|
||||||
|
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
|
||||||
|
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
||||||
|
// проекта не пишется в журнал и не считается в метрику.
|
||||||
|
//
|
||||||
|
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
|
||||||
|
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
|
||||||
|
// повышает.
|
||||||
|
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
|
||||||
|
if len(stages) == 0 {
|
||||||
|
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := clock.Now()
|
||||||
|
holder := ident.New()
|
||||||
|
|
||||||
|
args := []any{
|
||||||
|
sql.Named("holder", holder),
|
||||||
|
sql.Named("now", formatTime(now)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
|
||||||
|
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
|
||||||
|
// подставить его не может.
|
||||||
|
var expiry strings.Builder
|
||||||
|
expiry.WriteString("CASE state")
|
||||||
|
states := make([]string, 0, len(stages))
|
||||||
|
for i, stage := range stages {
|
||||||
|
stateKey := fmt.Sprintf("state%d", i)
|
||||||
|
expiryKey := fmt.Sprintf("expiry%d", i)
|
||||||
|
|
||||||
|
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
|
||||||
|
args = append(args,
|
||||||
|
sql.Named(stateKey, stage.Name),
|
||||||
|
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
|
||||||
|
)
|
||||||
|
states = append(states, ":"+stateKey)
|
||||||
|
}
|
||||||
|
expiry.WriteString(" END")
|
||||||
|
|
||||||
|
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
|
||||||
|
// Сравнения по неуникальному значению для этого мало — порядок обработки
|
||||||
|
// стал бы невоспроизводимым.
|
||||||
|
query := `
|
||||||
|
UPDATE ` + recordsTable + `
|
||||||
|
SET acquisition_id = :holder,
|
||||||
|
acquire_expires_at = ` + expiry.String() + `,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
updated_at = :now
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM ` + recordsTable + `
|
||||||
|
WHERE state IN (` + strings.Join(states, ", ") + `)
|
||||||
|
AND halted_at IS NULL
|
||||||
|
AND (delay_time IS NULL OR delay_time < :now)
|
||||||
|
AND (acquisition_id IS NULL
|
||||||
|
OR acquire_expires_at IS NULL
|
||||||
|
OR acquire_expires_at < :now)
|
||||||
|
ORDER BY created_at, id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING id`
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newWorkingRecord заводит запись, пригодную к захвату.
|
||||||
|
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
record := &entity.AudioRecord{
|
||||||
|
Id: ident.New(),
|
||||||
|
OwnerID: owner,
|
||||||
|
State: entity.StateUploaded,
|
||||||
|
StateEnteredAt: clock.Now(),
|
||||||
|
}
|
||||||
|
require.NoError(t, NewAudioRecordRepository(db).Create(record))
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
|
||||||
|
// связью с учётной записью и пустого значения не принимает.
|
||||||
|
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
|
||||||
|
t.Run("пустой владелец", func(t *testing.T) {
|
||||||
|
err := records.Create(&entity.AudioRecord{
|
||||||
|
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||||
|
})
|
||||||
|
assert.Error(t, err, "запись с пустым владельцем сохранилась")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
|
||||||
|
err := records.Create(&entity.AudioRecord{
|
||||||
|
Id: ident.New(), OwnerID: ident.New(),
|
||||||
|
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||||
|
})
|
||||||
|
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("запросом к базе тоже", func(t *testing.T) {
|
||||||
|
now := clock.Now().Format(timeLayout)
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(),
|
||||||
|
`INSERT INTO audio_records
|
||||||
|
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||||
|
VALUES (?, '', 0, 0, ?, ?, ?, ?)`,
|
||||||
|
ident.New(), entity.StateUploaded, now, now, now,
|
||||||
|
)
|
||||||
|
assert.Error(t, err, "ничья запись завелась запросом к базе")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл без владельца не сохраняется — тем же правилом схемы.
|
||||||
|
func TestFileWithoutOwnerIsRejected(t *testing.T) {
|
||||||
|
db, store, _ := newTestDB(t)
|
||||||
|
files := NewFileRepository(db, store)
|
||||||
|
|
||||||
|
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, work.Close()) }()
|
||||||
|
|
||||||
|
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
||||||
|
assert.Error(t, err, "файл с пустым владельцем сохранился")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
|
||||||
|
// запрет держит схема обязательной связью, а не проверка вызывающего.
|
||||||
|
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
|
||||||
|
db, store, _ := newTestDB(t)
|
||||||
|
|
||||||
|
t.Run("с аудиозаписями", func(t *testing.T) {
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
newWorkingRecord(t, db, owner)
|
||||||
|
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||||
|
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("с одними файлами", func(t *testing.T) {
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
files := NewFileRepository(db, store)
|
||||||
|
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, work.Close()) }()
|
||||||
|
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||||
|
assert.Error(t, err, "учётная запись с файлами удалилась")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("с одними темами", func(t *testing.T) {
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
now := clock.Now().Format(timeLayout)
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(),
|
||||||
|
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
ident.New(), owner, "тема", now, now,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||||
|
assert.Error(t, err, "учётная запись с темами удалилась")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("пустая удаляется", func(t *testing.T) {
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||||
|
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// У записи не больше пяти тем, и держит это схема.
|
||||||
|
func TestRecordTopicsAreCapped(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
|
||||||
|
now := clock.Now().Format(timeLayout)
|
||||||
|
for i := range entity.MaxTopicsPerRecord + 1 {
|
||||||
|
topicID := ident.New()
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(),
|
||||||
|
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
topicID, owner, "тема-"+topicID, now, now,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(),
|
||||||
|
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
|
||||||
|
)
|
||||||
|
if i < entity.MaxTopicsPerRecord {
|
||||||
|
require.NoError(t, err, "тема %d не назначилась", i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Error(t, err, "шестая тема назначилась")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
|
||||||
|
//
|
||||||
|
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
|
||||||
|
// одному, остальные получают признак «работы сейчас нет».
|
||||||
|
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
|
||||||
|
const racers = 8
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
acquired []*contract.AcquiredRecord
|
||||||
|
empty int
|
||||||
|
)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range racers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
|
||||||
|
got, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
var missing *contract.JobNotFoundError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
acquired = append(acquired, got)
|
||||||
|
case assert.ErrorAs(t, err, &missing):
|
||||||
|
empty++
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
require.Len(t, acquired, 1, "запись досталась не одному захвату")
|
||||||
|
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
|
||||||
|
assert.Equal(t, record.Id, acquired[0].ID)
|
||||||
|
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
|
||||||
|
// признака, а не занятостью записи.
|
||||||
|
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
|
||||||
|
first, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Захват уходит другому: срок протухает, и запись достаётся следующему.
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(),
|
||||||
|
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
|
||||||
|
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
second, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
|
||||||
|
|
||||||
|
// Прежний держатель пишет свой результат — и не пишет.
|
||||||
|
stale, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stale.MoveToState(entity.StateNormalized)
|
||||||
|
|
||||||
|
err = records.Save(stale, first.Holder)
|
||||||
|
|
||||||
|
var lost *contract.LostAcquisitionError
|
||||||
|
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
|
||||||
|
|
||||||
|
after, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
|
||||||
|
// которыми распоряжается сам.
|
||||||
|
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
|
||||||
|
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
held, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Владелец за это время правит поле, которого шаг не касается.
|
||||||
|
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
held.MoveToState(entity.StateNormalized)
|
||||||
|
require.NoError(t, records.Save(held, acquired.Holder))
|
||||||
|
|
||||||
|
after, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
|
||||||
|
require.NotNil(t, after.Title)
|
||||||
|
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Составная операция, которая читает и следом пишет, идёт по пишущему
|
||||||
|
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
|
||||||
|
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
texts := NewTextRepository(db)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
|
||||||
|
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
|
||||||
|
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
|
||||||
|
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
|
||||||
|
// стёрла бы расшифровку живого человека без следа.
|
||||||
|
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
|
||||||
|
db, store, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
|
||||||
|
texts := NewTextRepository(db)
|
||||||
|
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, stored.Id, again.Id, "строка та же")
|
||||||
|
|
||||||
|
read, err := texts.GetByID(stored.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
|
||||||
|
|
||||||
|
structures := NewStructureRepository(db)
|
||||||
|
first, err := structures.Put(record.Id, entity.StructureVersion,
|
||||||
|
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
structure, err := structures.GetByID(first.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
|
||||||
|
|
||||||
|
recognitions := NewRecognitionRepository(db, store)
|
||||||
|
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||||
|
require.NoError(t, recognitions.Create(attempt))
|
||||||
|
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
|
||||||
|
require.NoError(t, recognitions.Finish(attempt.Id, nil))
|
||||||
|
|
||||||
|
raw, err := recognitions.ReadRaw(attempt.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
|
||||||
|
// опроса читает строку попытки без него.
|
||||||
|
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
|
||||||
|
db, store, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
|
||||||
|
recognitions := NewRecognitionRepository(db, store)
|
||||||
|
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||||
|
require.NoError(t, recognitions.Create(attempt))
|
||||||
|
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
|
||||||
|
|
||||||
|
// Строка попытки читается без ответа: он не колонка.
|
||||||
|
read, err := recognitions.GetByID(attempt.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, read.FinishedAt)
|
||||||
|
|
||||||
|
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, file.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Два одновременных первых обращения одним значением дают ровно одну учётную
|
||||||
|
// запись: уникальность держит схема, а не порядок обращений.
|
||||||
|
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
users := NewUserRepository(db)
|
||||||
|
login := ident.New()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
ids = map[string]bool{}
|
||||||
|
)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
|
||||||
|
account, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||||
|
if assert.NoError(t, err) {
|
||||||
|
mu.Lock()
|
||||||
|
ids[account.ID] = true
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
require.NoError(t, db.Reader().
|
||||||
|
QueryRowContext(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
|
||||||
|
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
|
||||||
|
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
users := NewUserRepository(db)
|
||||||
|
|
||||||
|
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
assert.NotEqual(t, first.ID, second.ID)
|
||||||
|
|
||||||
|
var email string
|
||||||
|
require.NoError(t, db.Reader().
|
||||||
|
QueryRowContext(context.Background(),
|
||||||
|
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
|
||||||
|
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Найденную запись повторное обращение не переписывает: иначе правка имени у
|
||||||
|
// провайдера меняла бы карточку человека молча, посреди его работы.
|
||||||
|
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
users := NewUserRepository(db)
|
||||||
|
|
||||||
|
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
|
||||||
|
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, created)
|
||||||
|
assert.Equal(t, first.ID, again.ID)
|
||||||
|
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
|
||||||
|
// и занятый писатель его не держит.
|
||||||
|
//
|
||||||
|
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
|
||||||
|
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
|
||||||
|
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
|
||||||
|
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
|
||||||
|
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
|
||||||
|
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
|
||||||
|
// очередь к соединению ожиданием занятой базы не ограничена.
|
||||||
|
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
users := NewUserRepository(db)
|
||||||
|
login := ident.New()
|
||||||
|
|
||||||
|
first, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
|
||||||
|
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
|
||||||
|
// поэтому пока она держится, второй транзакции не начаться.
|
||||||
|
tx, err := db.Writer().BeginTx(context.Background(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
type answer struct {
|
||||||
|
account *contract.UserAccount
|
||||||
|
created bool
|
||||||
|
err error
|
||||||
|
took time.Duration
|
||||||
|
}
|
||||||
|
done := make(chan answer, 1)
|
||||||
|
go func() {
|
||||||
|
started := time.Now()
|
||||||
|
account, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||||
|
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
|
||||||
|
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
|
||||||
|
const limit = time.Second
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
require.NoError(t, got.err)
|
||||||
|
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
|
||||||
|
assert.Equal(t, first.ID, got.account.ID)
|
||||||
|
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
|
||||||
|
case <-time.After(limit):
|
||||||
|
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
|
||||||
|
// там, где никого не назвал.
|
||||||
|
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
users := NewUserRepository(db)
|
||||||
|
|
||||||
|
for name, login := range map[string]string{
|
||||||
|
"пустой": "",
|
||||||
|
"одни пробелы": " ",
|
||||||
|
"управляющий знак": "ali\x00ce",
|
||||||
|
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||||
|
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
|
||||||
|
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
|
||||||
|
//
|
||||||
|
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
|
||||||
|
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
|
||||||
|
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
|
||||||
|
// держит запись занятой до протухания срока, оставленное время входа в рубеж
|
||||||
|
// останавливает её снова первым же захватом, а оставленные отказы и пауза
|
||||||
|
// откладывают первый прогон на накопленный срок.
|
||||||
|
func TestResumeReleasesEveryGuard(t *testing.T) {
|
||||||
|
db, _, _ := newTestDB(t)
|
||||||
|
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
record := newWorkingRecord(t, db, owner)
|
||||||
|
records := NewAudioRecordRepository(db)
|
||||||
|
events := NewRecordEventRepository(db)
|
||||||
|
|
||||||
|
// Так выглядит запись, остановленная после долгих отказов: захват на ней
|
||||||
|
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
|
||||||
|
// рубеже она простояла дольше предела.
|
||||||
|
_, err := db.Writer().ExecContext(context.Background(), `
|
||||||
|
UPDATE audio_records
|
||||||
|
SET halted_at = ?, halt_reason = ?, error_text = ?,
|
||||||
|
acquisition_id = ?, acquire_expires_at = ?,
|
||||||
|
delay_time = ?, attempts = 7, state_entered_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
|
||||||
|
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
|
||||||
|
clock.Now().Add(time.Hour).Format(timeLayout),
|
||||||
|
clock.Now().Add(-48*time.Hour).Format(timeLayout),
|
||||||
|
record.Id,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Остановленная запись захвату не выдаётся.
|
||||||
|
_, err = records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
var missing *contract.JobNotFoundError
|
||||||
|
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
|
||||||
|
|
||||||
|
halted, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
halted.Resume()
|
||||||
|
require.NoError(t, records.Save(halted, ""))
|
||||||
|
require.NoError(t, events.Append(&entity.RecordEvent{
|
||||||
|
RecordID: record.Id,
|
||||||
|
Origin: entity.EventOriginHuman,
|
||||||
|
Step: "resume",
|
||||||
|
Outcome: entity.EventOutcomeResumed,
|
||||||
|
}))
|
||||||
|
|
||||||
|
after, err := records.Get(record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, after.IsHalted(), "признак остановки остался")
|
||||||
|
assert.Nil(t, after.HaltReason, "причина остановки осталась")
|
||||||
|
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
|
||||||
|
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
|
||||||
|
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
|
||||||
|
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
|
||||||
|
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
|
||||||
|
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
|
||||||
|
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
|
||||||
|
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
|
||||||
|
|
||||||
|
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
|
||||||
|
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||||
|
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
|
||||||
|
assert.Equal(t, record.Id, acquired.ID)
|
||||||
|
|
||||||
|
// И возврат виден в журнале событий записи — происхождением «человек».
|
||||||
|
var origin, outcome string
|
||||||
|
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
|
||||||
|
).Scan(&origin, &outcome))
|
||||||
|
assert.Equal(t, entity.EventOriginHuman, origin)
|
||||||
|
assert.Equal(t, entity.EventOutcomeResumed, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
|
||||||
|
// строки о готовности схема приведена целиком.
|
||||||
|
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
journal := &strings.Builder{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
|
||||||
|
db, err := Open(dir, testSettings())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, Migrate(context.Background(), db, dir, logger))
|
||||||
|
|
||||||
|
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
|
||||||
|
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
|
||||||
|
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
|
||||||
|
|
||||||
|
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
|
||||||
|
// первым приёмом нет.
|
||||||
|
owner := newOwner(t, db)
|
||||||
|
newWorkingRecord(t, db, owner)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
|
||||||
|
const recordsDir = "records"
|
||||||
|
|
||||||
|
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
|
||||||
|
// имена из обычного перечисления каталога, а сама приставка отличает
|
||||||
|
// незавершённую укладку от рабочего имени.
|
||||||
|
const tempPrefix = ".partial-"
|
||||||
|
|
||||||
|
// Store — файлы записей в каталоге данных.
|
||||||
|
//
|
||||||
|
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
|
||||||
|
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
|
||||||
|
// запись убирается целиком одним движением — плоский каталог, где копии
|
||||||
|
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
|
||||||
|
//
|
||||||
|
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
|
||||||
|
// файла, ни именем каталога.
|
||||||
|
type Store struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore заводит раздел записей в каталоге данных.
|
||||||
|
func NewStore(dataDir string) *Store {
|
||||||
|
return &Store{root: filepath.Join(dataDir, recordsDir)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dir — подкаталог одной записи.
|
||||||
|
func (s *Store) dir(recordID string) string {
|
||||||
|
return filepath.Join(s.root, recordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
|
||||||
|
// в ответ, ни в метку метрики.
|
||||||
|
func (s *Store) path(recordID, name string) string {
|
||||||
|
return filepath.Join(s.dir(recordID), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put кладёт содержимое под рабочим именем **атомарно**.
|
||||||
|
//
|
||||||
|
// Содержимое пишется во временное имя в том же подкаталоге записи и
|
||||||
|
// переименовывается в рабочее только после того, как поток дочитан до конца без
|
||||||
|
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
|
||||||
|
// его пределах не копирует содержимое и не может оборваться на середине.
|
||||||
|
//
|
||||||
|
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
|
||||||
|
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
|
||||||
|
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
|
||||||
|
// результат — атомарная укладка единственное, что этого не допускает.
|
||||||
|
//
|
||||||
|
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
|
||||||
|
// имени не остаётся, рабочего имени не появляется.
|
||||||
|
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
|
||||||
|
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
temp := s.path(recordID, tempPrefix+ident.New())
|
||||||
|
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
size, copyErr := io.Copy(file, src)
|
||||||
|
syncErr := file.Sync()
|
||||||
|
closeErr := file.Close()
|
||||||
|
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
|
||||||
|
_ = os.Remove(temp)
|
||||||
|
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
|
||||||
|
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
|
||||||
|
// но по причине владелец различает исчерпание места, отсутствие прав и
|
||||||
|
// файловую систему только для чтения.
|
||||||
|
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
|
||||||
|
_ = os.Remove(temp)
|
||||||
|
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
|
||||||
|
// по диапазону читает кусок, а не файл целиком.
|
||||||
|
func (s *Store) Open(recordID, name string) (*os.File, error) {
|
||||||
|
file, err := os.Open(s.path(recordID, name))
|
||||||
|
if err != nil {
|
||||||
|
// Отказ называет запись её идентификатором и не несёт имени файла:
|
||||||
|
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
|
||||||
|
// нет» и «прав нет» ведут владельца к разным действиям.
|
||||||
|
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
|
||||||
|
// там, где укладка до него не дошла.
|
||||||
|
func (s *Store) Remove(recordID, name string) error {
|
||||||
|
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
|
||||||
|
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
|
||||||
|
// отсутствию рабочего имени.
|
||||||
|
func (s *Store) HasTemporary(recordID string) (bool, error) {
|
||||||
|
entries, err := os.ReadDir(s.dir(recordID))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// causeOf снимает с отказа файловой операции путь, оставляя причину.
|
||||||
|
//
|
||||||
|
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
|
||||||
|
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
|
||||||
|
// только для чтения — три поломки, требующие трёх разных действий, а отказ
|
||||||
|
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
|
||||||
|
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
|
||||||
|
// строку потом не убрать.
|
||||||
|
//
|
||||||
|
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
|
||||||
|
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
|
||||||
|
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
|
||||||
|
// обёрткой и от её снятия не страдает.
|
||||||
|
//
|
||||||
|
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
|
||||||
|
// сброса и закрытия, и путь лежит в каждой из них.
|
||||||
|
func causeOf(err error) error {
|
||||||
|
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
|
||||||
|
case *os.PathError:
|
||||||
|
return typed.Err
|
||||||
|
case *os.LinkError:
|
||||||
|
return typed.Err
|
||||||
|
case interface{ Unwrap() []error }:
|
||||||
|
parts := typed.Unwrap()
|
||||||
|
causes := make([]error, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
causes = append(causes, causeOf(part))
|
||||||
|
}
|
||||||
|
return errors.Join(causes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTextRepository(db *DB) *TextRepository {
|
||||||
|
return &TextRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put кладёт текст записи, заменяя прежний того же вида.
|
||||||
|
//
|
||||||
|
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||||
|
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||||
|
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||||
|
//
|
||||||
|
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
||||||
|
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
||||||
|
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
|
||||||
|
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
||||||
|
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
||||||
|
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
|
||||||
|
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
|
||||||
|
//
|
||||||
|
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
|
||||||
|
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
|
||||||
|
// вторую строку на гонке двух шагов.
|
||||||
|
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||||
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
existing string
|
||||||
|
)
|
||||||
|
err = tx.QueryRowContext(context.Background(),
|
||||||
|
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
|
||||||
|
).Scan(&id, &existing)
|
||||||
|
|
||||||
|
now := formatTime(clock.Now())
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
||||||
|
// есть, и вызывающий получает её обратно.
|
||||||
|
if contents == "" && existing != "" {
|
||||||
|
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(context.Background(),
|
||||||
|
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
|
||||||
|
); err != nil {
|
||||||
|
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
|
||||||
|
// драйвера несёт значение поля.
|
||||||
|
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
||||||
|
}
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
id = ident.New()
|
||||||
|
if _, err := tx.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, recordID, kind, contents, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
|
||||||
|
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
|
||||||
|
// вместо правды о недоступной базе.
|
||||||
|
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
|
||||||
|
text := &entity.Text{Id: id}
|
||||||
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
|
||||||
|
).Scan(&text.RecordID, &text.Kind, &text.Contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructureRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStructureRepository(db *DB) *StructureRepository {
|
||||||
|
return &StructureRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
|
||||||
|
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
|
||||||
|
// перечень реплик поверх непустого не кладётся.
|
||||||
|
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
|
||||||
|
if replicas == nil {
|
||||||
|
replicas = []entity.Replica{}
|
||||||
|
}
|
||||||
|
contents, err := json.Marshal(replicas)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
existing string
|
||||||
|
)
|
||||||
|
err = tx.QueryRowContext(context.Background(),
|
||||||
|
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
|
||||||
|
).Scan(&id, &existing)
|
||||||
|
|
||||||
|
now := formatTime(clock.Now())
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if len(replicas) == 0 && len(existing) > len("[]") {
|
||||||
|
stored, decodeErr := decodeReplicas(id, existing)
|
||||||
|
if decodeErr != nil {
|
||||||
|
return nil, decodeErr
|
||||||
|
}
|
||||||
|
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(context.Background(),
|
||||||
|
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
||||||
|
}
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
id = ident.New()
|
||||||
|
if _, err := tx.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, recordID, version, string(contents), now, now,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
|
||||||
|
structure := &entity.Structure{Id: id}
|
||||||
|
var contents string
|
||||||
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||||
|
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
|
||||||
|
).Scan(&structure.RecordID, &structure.Version, &contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicas, err := decodeReplicas(id, contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
structure.Replicas = replicas
|
||||||
|
|
||||||
|
return structure, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
|
||||||
|
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
|
||||||
|
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
|
||||||
|
if contents == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var replicas []entity.Replica
|
||||||
|
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode structure %s", id)
|
||||||
|
}
|
||||||
|
return replicas, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sqlitedriver "modernc.org/sqlite"
|
||||||
|
sqlitelib "modernc.org/sqlite/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
|
||||||
|
// секундная точность.
|
||||||
|
//
|
||||||
|
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
|
||||||
|
// совпадает с хронологией, и отбор по колонке времени работает без разбора
|
||||||
|
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
|
||||||
|
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
|
||||||
|
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
|
||||||
|
// не выдавалась бы ни одному воркеру никогда.
|
||||||
|
const timeLayout = "2006-01-02T15:04:05Z"
|
||||||
|
|
||||||
|
// formatTime приводит метку времени к виду колонки.
|
||||||
|
func formatTime(v time.Time) string {
|
||||||
|
return v.UTC().Format(timeLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
|
||||||
|
// и отсутствующее — одно и то же: «времени нет».
|
||||||
|
func timeValue(v *time.Time) any {
|
||||||
|
if v == nil || v.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return formatTime(*v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
|
||||||
|
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
|
||||||
|
func timeOf(v sql.NullString) *time.Time {
|
||||||
|
if !v.Valid || v.String == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(timeLayout, v.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed = parsed.UTC()
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// requiredTimeOf читает обязательную колонку времени.
|
||||||
|
func requiredTimeOf(v string) time.Time {
|
||||||
|
parsed, err := time.Parse(timeLayout, v)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return parsed.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
|
||||||
|
// же.
|
||||||
|
func stringValue(v *string) any {
|
||||||
|
if v == nil || *v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringOf читает необязательную строку.
|
||||||
|
func stringOf(v sql.NullString) *string {
|
||||||
|
if !v.Valid || v.String == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := v.String
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberOf читает необязательное число.
|
||||||
|
//
|
||||||
|
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
|
||||||
|
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
|
||||||
|
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
|
||||||
|
// отсутствие.
|
||||||
|
func numberOf(v int64) *int64 {
|
||||||
|
out := v
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberValue кладёт необязательное число нулём: колонка обязательна.
|
||||||
|
func numberValue(v *int64) int64 {
|
||||||
|
if v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertSQL собирает вставку из карты «колонка → значение».
|
||||||
|
//
|
||||||
|
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
|
||||||
|
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
|
||||||
|
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
|
||||||
|
// имени такого сдвига не существует вовсе.
|
||||||
|
//
|
||||||
|
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
|
||||||
|
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
|
||||||
|
// журналу читала бы каждый раз новый запрос.
|
||||||
|
func insertSQL(table string, values map[string]any) (string, []any) {
|
||||||
|
names := sortedNames(values)
|
||||||
|
|
||||||
|
placeholders := make([]string, 0, len(names))
|
||||||
|
args := make([]any, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
placeholders = append(placeholders, ":"+name)
|
||||||
|
args = append(args, sql.Named(name, values[name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"INSERT INTO %s (%s) VALUES (%s)",
|
||||||
|
table,
|
||||||
|
strings.Join(names, ", "),
|
||||||
|
strings.Join(placeholders, ", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSQL собирает правку из карты «колонка → значение» и условия.
|
||||||
|
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
|
||||||
|
names := sortedNames(values)
|
||||||
|
|
||||||
|
assignments := make([]string, 0, len(names))
|
||||||
|
args := make([]any, 0, len(names)+len(whereArgs))
|
||||||
|
for _, name := range names {
|
||||||
|
assignments = append(assignments, name+" = :"+name)
|
||||||
|
args = append(args, sql.Named(name, values[name]))
|
||||||
|
}
|
||||||
|
args = append(args, whereArgs...)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"UPDATE %s SET %s WHERE %s",
|
||||||
|
table,
|
||||||
|
strings.Join(assignments, ", "),
|
||||||
|
where,
|
||||||
|
)
|
||||||
|
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedNames(values map[string]any) []string {
|
||||||
|
names := make([]string, 0, len(values))
|
||||||
|
for name := range values {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectList собирает перечень колонок для выборки из той же карты, по которой
|
||||||
|
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
|
||||||
|
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
|
||||||
|
func selectList(targets map[string]any, prefix string) (string, []any) {
|
||||||
|
names := sortedNames(targets)
|
||||||
|
|
||||||
|
columns := make([]string, 0, len(names))
|
||||||
|
scan := make([]any, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
columns = append(columns, prefix+name)
|
||||||
|
scan = append(scan, targets[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(columns, ", "), scan
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
|
||||||
|
//
|
||||||
|
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
|
||||||
|
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
|
||||||
|
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
|
||||||
|
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var sqliteErr *sqlitedriver.Error
|
||||||
|
if !errors.As(err, &sqliteErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
|
||||||
|
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package telegram
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TextLengthLimit = 4000
|
|
||||||
)
|
|
||||||
|
|
||||||
type TelegramMessageSender struct {
|
|
||||||
bot *tgbotapi.BotAPI
|
|
||||||
logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
|
||||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TelegramMessageSender{
|
|
||||||
bot: bot,
|
|
||||||
logger: logger,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
|
||||||
// If message is short enough, send it directly
|
|
||||||
if len([]rune(text)) <= TextLengthLimit {
|
|
||||||
return s.sendSingleMessage(text, chatId, replyToMessageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split long message into parts
|
|
||||||
parts := s.splitMessageByWords(text, TextLengthLimit)
|
|
||||||
|
|
||||||
// Send each part
|
|
||||||
for i, part := range parts {
|
|
||||||
var replyId *int
|
|
||||||
// Only use replyToMessageId for the first part
|
|
||||||
if i == 0 {
|
|
||||||
replyId = replyToMessageId
|
|
||||||
}
|
|
||||||
err := s.sendSingleMessage(part, chatId, replyId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendSingleMessage sends a single message
|
|
||||||
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
|
|
||||||
resultMsg := tgbotapi.NewMessage(chatId, text)
|
|
||||||
if replyToMessageId != nil {
|
|
||||||
resultMsg.ReplyToMessageID = *replyToMessageId
|
|
||||||
}
|
|
||||||
_, err := s.bot.Send(resultMsg)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to send message to tg bot", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package telegram
|
|
||||||
|
|
||||||
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
|
|
||||||
// splitting by words to avoid cutting words in the middle
|
|
||||||
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
// If text is already short enough, return as is
|
|
||||||
if len([]rune(text)) <= maxLen {
|
|
||||||
return []string{text}
|
|
||||||
}
|
|
||||||
|
|
||||||
runes := []rune(text)
|
|
||||||
|
|
||||||
for len(runes) > 0 {
|
|
||||||
// Determine the end position for this part
|
|
||||||
end := len(runes)
|
|
||||||
if end > maxLen {
|
|
||||||
end = maxLen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find a good split point (word boundary)
|
|
||||||
splitPoint := end
|
|
||||||
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
|
|
||||||
// Check if this is a good split point (after a space)
|
|
||||||
if runes[i] == ' ' {
|
|
||||||
splitPoint = i + 1 // Include the space in the previous part
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we couldn't find a good split point, just split at maxLen
|
|
||||||
if splitPoint == end && end == maxLen {
|
|
||||||
// Check if we're in the middle of a word
|
|
||||||
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
|
|
||||||
// Try to find a split point going forward
|
|
||||||
for i := end; i < len(runes) && i < end+20; i++ {
|
|
||||||
if runes[i] == ' ' {
|
|
||||||
splitPoint = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still no good split point, use the original end
|
|
||||||
if splitPoint > len(runes) {
|
|
||||||
splitPoint = len(runes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this part
|
|
||||||
parts = append(parts, string(runes[:splitPoint]))
|
|
||||||
|
|
||||||
// Move to the next part
|
|
||||||
if splitPoint >= len(runes) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
runes = runes[splitPoint:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user