Phase 1: Go backend scaffold

- Project structure: cmd/boosapi, internal/, pkg/
- Config loader (all env vars from Node.js original)
- Database layer with SQLite/MySQL/Postgres support
- Migration runner (sequential SQL files)
- Health check endpoint (GET /healthz)
- HTTP middleware: logger, auth, CORS, panic recovery
- chi router with /admin, /v1, /api route groups
- Graceful shutdown (SIGINT/SIGTERM)
- Multi-stage Dockerfile (alpine, ~20MB image)
- Initial schema migration (users, api_keys, sites, usage_logs)

Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
boos
2026-05-16 01:07:02 +08:00
parent f0188d3f68
commit b6ecd613d4
9 changed files with 686 additions and 88 deletions
+138
View File
@@ -0,0 +1,138 @@
// Package db provides database connection management and migration support.
// Supports SQLite, MySQL, and PostgreSQL backends.
package db
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
// DBType constants.
const (
TypeSQLite = "sqlite"
TypeMySQL = "mysql"
TypePostgres = "postgres"
)
// Open opens a database connection based on the configuration.
func Open(dbType, dbURL, dataDir string) (*sql.DB, error) {
var driverName, dsn string
switch dbType {
case TypeSQLite:
driverName = "sqlite"
if dbURL == "" {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
dbURL = filepath.Join(dataDir, "hub.db")
}
dsn = dbURL + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
case TypeMySQL:
driverName = "mysql"
dsn = dbURL
if dsn == "" {
return nil, errors.New("DB_URL required for mysql")
}
case TypePostgres:
driverName = "postgres"
dsn = dbURL
if dsn == "" {
return nil, errors.New("DB_URL required for postgres")
}
default:
return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType)
}
db, err := sql.Open(driverName, dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", dbType, err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping %s: %w", dbType, err)
}
slog.Info("database connected", "type", dbType)
return db, nil
}
// RunMigrations reads SQL migration files from the given directory and applies
// them in filename order. Each file is executed within a transaction.
// Migration filenames should follow the format: 000001_description.up.sql
func RunMigrations(db *sql.DB, migrationsDir string) error {
entries, err := os.ReadDir(migrationsDir)
if err != nil {
if os.IsNotExist(err) {
slog.Warn("migrations directory not found, skipping", "dir", migrationsDir)
return nil
}
return fmt.Errorf("read migrations dir: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, f := range files {
path := filepath.Join(migrationsDir, f)
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", f, err)
}
// Skip empty files
sql := strings.TrimSpace(string(content))
if sql == "" {
continue
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", f, err)
}
if _, err := tx.Exec(sql); err != nil {
tx.Rollback()
return fmt.Errorf("execute migration %s: %w", f, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", f, err)
}
slog.Info("migration applied", "file", f)
}
return nil
}
// Close gracefully closes the database connection.
func Close(db *sql.DB) {
if db != nil {
db.Close()
}
}