Refactoring: clean architecture project structure

This commit is contained in:
2025-08-09 15:18:42 +03:00
parent 40e207bdb2
commit 8e133630d4
12 changed files with 265 additions and 188 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# App binary
transcriber
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~

View File

@@ -1,117 +0,0 @@
package database
import (
"database/sql"
"fmt"
"log"
"git.vakhrushev.me/av/transcriber/models"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
)
type DB struct {
conn *sql.DB
gq *goqu.Database
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
gq := goqu.New("sqlite3", conn)
db := &DB{
conn: conn,
gq: gq,
}
return db, nil
}
func (db *DB) RunMigrations(migrationsDir string) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
if err := goose.Up(db.conn, migrationsDir); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Println("Migrations completed successfully")
return nil
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) CreateFile(file *models.File) error {
query := db.gq.Insert("files").Rows(file)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = db.conn.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
return nil
}
func (db *DB) CreateTranscribeJob(job *models.TranscribeJob) error {
query := db.gq.Insert("transcribe_jobs").Rows(job)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = db.conn.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
return nil
}
func (db *DB) GetFileByID(id string) (*models.File, error) {
query := db.gq.From("files").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var file models.File
err = db.conn.QueryRow(sql, args...).Scan(&file.ID, &file.Type, &file.Size, &file.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return &file, nil
}
func (db *DB) GetTranscribeJobByID(id string) (*models.TranscribeJob, error) {
query := db.gq.From("transcribe_jobs").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job models.TranscribeJob
err = db.conn.QueryRow(sql, args...).Scan(&job.ID, &job.Status, &job.FileID, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}

View File

@@ -1,4 +1,4 @@
package handlers package http
import ( import (
"fmt" "fmt"
@@ -8,27 +8,27 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.vakhrushev.me/av/transcriber/database" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/models" "git.vakhrushev.me/av/transcriber/internal/repo"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
type TranscribeHandler struct { type TranscribeHandler struct {
db *database.DB jobRepo repo.TranscriptJobRepository
fileRepo repo.FileRepository
} }
func NewTranscribeHandler(db *database.DB) *TranscribeHandler { func NewTranscribeHandler(jobRepo repo.TranscriptJobRepository, fileRepo repo.FileRepository) *TranscribeHandler {
return &TranscribeHandler{db: db} return &TranscribeHandler{jobRepo: jobRepo, fileRepo: fileRepo}
} }
type TranscribeResponse struct { type TranscribeResponse struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
FileID string `json:"file_id"` State string `json:"status"`
Status string `json:"status"`
} }
func (h *TranscribeHandler) UploadAndTranscribe(c *gin.Context) { func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) {
// Получаем файл из формы // Получаем файл из формы
file, header, err := c.Request.FormFile("audio") file, header, err := c.Request.FormFile("audio")
if err != nil { if err != nil {
@@ -38,7 +38,7 @@ func (h *TranscribeHandler) UploadAndTranscribe(c *gin.Context) {
defer file.Close() defer file.Close()
// Генерируем UUID для файла // Генерируем UUID для файла
fileID := uuid.New().String() fileId := uuid.New().String()
// Определяем расширение файла // Определяем расширение файла
ext := filepath.Ext(header.Filename) ext := filepath.Ext(header.Filename)
@@ -47,7 +47,7 @@ func (h *TranscribeHandler) UploadAndTranscribe(c *gin.Context) {
} }
// Создаем путь для сохранения файла // Создаем путь для сохранения файла
fileName := fmt.Sprintf("%s%s", fileID, ext) fileName := fmt.Sprintf("%s%s", fileId, ext)
filePath := filepath.Join("data", "files", fileName) filePath := filepath.Join("data", "files", fileName)
// Создаем файл на диске // Создаем файл на диске
@@ -66,14 +66,14 @@ func (h *TranscribeHandler) UploadAndTranscribe(c *gin.Context) {
} }
// Создаем запись в таблице files // Создаем запись в таблице files
fileRecord := &models.File{ fileRecord := &entity.File{
ID: fileID, Id: fileId,
Type: header.Header.Get("Content-Type"), Storage: entity.StorageLocal,
Size: size, Size: size,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := h.db.CreateFile(fileRecord); err != nil { if err := h.fileRepo.Create(fileRecord); err != nil {
// Удаляем файл если не удалось создать запись в БД // Удаляем файл если не удалось создать запись в БД
os.Remove(filePath) os.Remove(filePath)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file record"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file record"})
@@ -81,34 +81,33 @@ func (h *TranscribeHandler) UploadAndTranscribe(c *gin.Context) {
} }
// Создаем запись в таблице transcribe_jobs // Создаем запись в таблице transcribe_jobs
jobID := uuid.New().String() jobId := uuid.New().String()
job := &models.TranscribeJob{ job := &entity.TranscribeJob{
ID: jobID, Id: jobId,
Status: models.StatusPending, State: entity.StateCreated,
FileID: fileID, FileID: &fileId,
IsError: false,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
if err := h.db.CreateTranscribeJob(job); err != nil { if err := h.jobRepo.Create(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcribe job"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcribe job"})
return return
} }
// Возвращаем успешный ответ // Возвращаем успешный ответ
response := TranscribeResponse{ response := TranscribeResponse{
JobID: jobID, JobID: job.Id,
FileID: fileID, State: job.State,
Status: models.StatusPending,
} }
c.JSON(http.StatusCreated, response) c.JSON(http.StatusCreated, response)
} }
func (h *TranscribeHandler) GetJobStatus(c *gin.Context) { func (h *TranscribeHandler) GetTranscribeJobStatus(c *gin.Context) {
jobID := c.Param("id") jobID := c.Param("id")
job, err := h.db.GetTranscribeJobByID(jobID) job, err := h.jobRepo.GetByID(jobID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return return

17
internal/entity/file.go Normal file
View File

@@ -0,0 +1,17 @@
package entity
import (
"time"
)
const (
StorageLocal = "local"
StorageS3 = "s3"
)
type File struct {
Id string
Storage string
Size int64
CreatedAt time.Time
}

23
internal/entity/job.go Normal file
View File

@@ -0,0 +1,23 @@
package entity
import (
"time"
)
type TranscribeJob struct {
Id string
State string
FileID *string
IsError bool
ErrorText *string
Worker *string
AcquiredAt *time.Time
CreatedAt time.Time
}
const (
StateCreated = "created"
StateConverted = "converted"
StateUploaded = "uploaded"
StatusFailed = "failed"
)

View File

@@ -0,0 +1,16 @@
package repo
import "git.vakhrushev.me/av/transcriber/internal/entity"
type FileRepository interface {
Create(file *entity.File) error
GetByID(id string) (*entity.File, error)
}
type TranscriptJobRepository interface {
Create(job *entity.TranscribeJob) error
GetByID(id string) (*entity.TranscribeJob, error)
}
type ObjectStorage interface {
}

View File

@@ -0,0 +1,55 @@
package sqlite
import (
"database/sql"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/doug-martin/goqu/v9"
)
type FileRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewFileRepository(conn *sql.DB, gq *goqu.Database) *FileRepository {
return &FileRepository{conn, gq}
}
func (repo *FileRepository) Create(file *entity.File) error {
record := goqu.Record{
"id": file.Id,
"storage": file.Storage,
"size": file.Size,
"created_at": file.CreatedAt,
}
query := repo.gq.Insert("files").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
return nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
query := repo.gq.From("files").Select("id", "storage", "size", "created_at").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var file entity.File
err = repo.db.QueryRow(sql, args...).Scan(&file.Id, &file.Storage, &file.Size, &file.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return &file, nil
}

View File

@@ -0,0 +1,77 @@
package sqlite
import (
"database/sql"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/doug-martin/goqu/v9"
)
type TranscriptJobRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRepository {
return &TranscriptJobRepository{db, gq}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
record := goqu.Record{
"id": job.Id,
"state": job.State,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"worker": job.Worker,
"acquired_at": job.AcquiredAt,
"created_at": job.CreatedAt,
}
query := repo.gq.Insert("transcribe_jobs").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
query := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"file_id",
"is_error",
"error_text",
"worker",
"acquired_at",
"created_at",
).Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job entity.TranscribeJob
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.FileID,
&job.IsError,
&job.ErrorText,
&job.Worker,
&job.AcquiredAt,
&job.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}

49
main.go
View File

@@ -1,12 +1,18 @@
package main package main
import ( import (
"database/sql"
"fmt"
"log" "log"
"os" "os"
"git.vakhrushev.me/av/transcriber/database" "git.vakhrushev.me/av/transcriber/internal/controller/http"
"git.vakhrushev.me/av/transcriber/handlers" "git.vakhrushev.me/av/transcriber/internal/repo/sqlite"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
) )
func main() { func main() {
@@ -15,29 +21,37 @@ func main() {
log.Fatal("Failed to create data/files directory:", err) log.Fatal("Failed to create data/files directory:", err)
} }
// Инициализируем базу данных db, err := sql.Open("sqlite3", "data/transcriber.db")
db, err := database.New("data/transcriber.db")
if err != nil { if err != nil {
log.Fatal("Failed to initialize database:", err) log.Fatalf("failed to open database: %v", err)
} }
defer db.Close() defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
gq := goqu.New("sqlite3", db)
// Запускаем миграции // Запускаем миграции
if err := db.RunMigrations("migrations"); err != nil { if err := RunMigrations(db, "migrations"); err != nil {
log.Fatal("Failed to run migrations:", err) log.Fatal("Failed to run migrations:", err)
} }
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
// Инициализируем обработчики
transcribeHandler := http.NewTranscribeHandler(jobRepo, fileRepo)
// Создаем Gin роутер // Создаем Gin роутер
r := gin.Default() r := gin.Default()
// Инициализируем обработчики
transcribeHandler := handlers.NewTranscribeHandler(db)
// Настраиваем роуты // Настраиваем роуты
api := r.Group("/api") api := r.Group("/api")
{ {
api.POST("/transcribe", transcribeHandler.UploadAndTranscribe) api.POST("/transcribe/audio", transcribeHandler.CreateTranscribeJob)
api.GET("/transcribe/:id", transcribeHandler.GetJobStatus) api.GET("/transcribe/:id", transcribeHandler.GetTranscribeJobStatus)
} }
// Добавляем middleware для обработки больших файлов // Добавляем middleware для обработки больших файлов
@@ -56,3 +70,16 @@ func main() {
log.Fatal("Failed to start server:", err) log.Fatal("Failed to start server:", err)
} }
} }
func RunMigrations(db *sql.DB, migrationsDir string) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
if err := goose.Up(db, migrationsDir); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Println("Migrations completed successfully")
return nil
}

View File

@@ -1,7 +1,7 @@
-- +goose Up -- +goose Up
CREATE TABLE files ( CREATE TABLE files (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
type TEXT NOT NULL, storage TEXT NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );

View File

@@ -1,10 +1,14 @@
-- +goose Up -- +goose Up
CREATE TABLE transcribe_jobs ( CREATE TABLE transcribe_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending', state TEXT NOT NULL,
file_id TEXT NOT NULL, file_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_error BOOLEAN NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, error_text TEXT,
worker TEXT,
acquired_at DATETIME,
created_at DATETIME NOT NULL,
FOREIGN KEY (file_id) REFERENCES files(id) FOREIGN KEY (file_id) REFERENCES files(id)
); );

View File

@@ -1,27 +0,0 @@
package models
import (
"time"
)
type File struct {
ID string `db:"id" json:"id"`
Type string `db:"type" json:"type"`
Size int64 `db:"size" json:"size"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type TranscribeJob struct {
ID string `db:"id" json:"id"`
Status string `db:"status" json:"status"`
FileID string `db:"file_id" json:"file_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusCompleted = "completed"
StatusFailed = "failed"
)