хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+7 -3
View File
@@ -7,8 +7,9 @@
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт
// подкомандой `admin` вместе с задачей о запуске одной командой.
// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
// остановленной записи в работу. Вторая встала на место панели владельца:
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
//
// Имена заголовков берутся **константами транспорта**, а не литералами: они
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
@@ -43,6 +44,8 @@ func main() {
switch os.Args[1] {
case "proxy":
runProxy(os.Args[2:])
case "resume":
runResume(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage()
@@ -54,7 +57,8 @@ func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
resume вернуть остановленную запись в работу
`)
}
+100
View File
@@ -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)
}