хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
+89 -95
View File
@@ -2,8 +2,7 @@ package main
import (
"context"
"database/sql"
"embed"
"errors"
"flag"
"fmt"
"log/slog"
@@ -17,29 +16,17 @@ import (
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"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp"
sloggin "github.com/samber/slog-gin"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
const (
ServerShutdownTimeout = 5
ForceShutdownTimeout = 20
)
func main() {
@@ -68,35 +55,25 @@ func main() {
logger.Warn("Warning: .env file not found, using system environment variables")
}
// Создаем директории если они не существуют
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil {
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err)
os.Exit(1)
}
db, err := sql.Open("sqlite3", cfg.Database.Path)
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
// непринятые шаги прежде, чем поднять сервер.
storage, err := pbrepo.New(cfg.Storage.DataDir)
if err != nil {
logger.Error("failed to open database", "error", err)
logger.Error("Failed to open storage", "error", err)
os.Exit(1)
}
defer db.Close()
defer func() {
if err := storage.ResetBootstrapState(); err != nil {
logger.Error("Failed to close storage", "error", err)
}
}()
if err := db.Ping(); err != nil {
logger.Error("failed to ping database", "error", err)
os.Exit(1)
}
gq := goqu.New("sqlite3", db)
// Запускаем миграции
if err := RunMigrations(db, logger); err != nil {
logger.Error("Failed to run migrations", "error", err)
os.Exit(1)
}
pbrepo.BindPanelRules(storage)
// Создаем репозитории
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
fileRepo := pbrepo.NewFileRepository(storage)
jobRepo := pbrepo.NewTranscriptJobRepository(storage)
// Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
@@ -139,7 +116,6 @@ func main() {
converter,
recognizer,
tgSender,
cfg.Storage.Path,
logger,
)
@@ -193,49 +169,74 @@ func main() {
}(w)
}
// Создаем Gin middleware для логирования
gin.SetMode(gin.DebugMode)
router := gin.New()
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
// Запускаем HTTP сервер для API (создание задач и проверка статуса)
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService)
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
// горутине сервера, а читает его горутина остановки, и связи «произошло
// раньше» между ними иначе нет.
srvCh := make(chan *http.Server, 1)
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
// целенаправленной нагрузке объявлена вне модели угроз проекта.
se.Server.ReadTimeout = 0
srvCh <- se.Server
// Настраиваем роуты только для создания задач и проверки статуса
api := router.Group("/api")
{
api.POST("/audio", transcribeHandler.CreateTranscribeJob)
api.GET("/status/:id", transcribeHandler.GetTranscribeJobStatus)
}
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
// журнале контейнера не видно. Поля — те, что просит конвенция.
se.Router.BindFunc(func(e *core.RequestEvent) error {
start := time.Now()
err := e.Next()
// Добавляем middleware для обработки больших файлов
router.MaxMultipartMemory = 32 << 20 // 32 MiB
level := slog.LevelInfo
if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
level = slog.LevelDebug
}
// Добавляем базовый роут для проверки работоспособности
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"message": "Transcriber service is running",
logger.Log(e.Request.Context(), level, "Incoming request",
"http.method", e.Request.Method,
"http.route", e.Request.URL.Path,
"http.status_code", e.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"transport", "http")
return err
})
transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok",
"message": "Transcriber service is running",
})
})
se.Router.GET("/metrics", func(e *core.RequestEvent) error {
promhttp.Handler().ServeHTTP(e.Response, e.Request)
return nil
})
return se.Next()
})
// Добавляем эндпоинт для метрик Prometheus
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
// Создаем HTTP сервер
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
// Запускаем HTTP сервер в отдельной горутине
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 && err != http.ErrServerClosed {
err := apis.Serve(storage, apis.ServeConfig{
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
ShowStartBanner: false,
})
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP server error", "error", err)
serveErr <- err
}
}()
@@ -247,9 +248,13 @@ func main() {
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения
<-sigChan
logger.Info("Received shutdown signal, initiating graceful shutdown...")
// Ждем сигнал завершения либо отказ сервера
select {
case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...")
case <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down")
}
if tgController != nil {
logger.Info("Shutting down Telegram bot...")
@@ -261,11 +266,16 @@ func main() {
defer shutdownCancel()
// Останавливаем HTTP сервер
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")
select {
case srv := <-srvCh:
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")
}
default:
logger.Info("HTTP server was not started, nothing to shut down")
}
// Отменяем контекст для остановки воркеров
@@ -288,19 +298,3 @@ func main() {
logger.Info("Transcriber service stopped")
}
func RunMigrations(db *sql.DB, logger *slog.Logger) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
// Use the embedded filesystem for migrations
goose.SetBaseFS(migrationsFS)
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
logger.Info("Migrations completed successfully")
return nil
}