feat(backend): establish gin sqlite service skeleton

This commit is contained in:
QiuSW
2026-07-25 22:29:56 +08:00
parent 84f2da3cf7
commit ab21219a07
32 changed files with 1854 additions and 42 deletions
@@ -0,0 +1,87 @@
package database
import (
"context"
"database/sql"
"errors"
"net/url"
"os"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3"
)
const busyTimeoutMilliseconds = 5000
type safeError struct {
message string
cause error
}
func (e safeError) Error() string {
return e.message
}
func (e safeError) Unwrap() error {
return e.cause
}
func Open(ctx context.Context, path string) (*sql.DB, error) {
absolutePath, err := filepath.Abs(path)
if err != nil {
return nil, errors.New("resolve SQLite path")
}
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o700); err != nil {
return nil, errors.New("create SQLite directory")
}
if info, err := os.Stat(absolutePath); err == nil && info.IsDir() {
return nil, errors.New("SQLite path is a directory")
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.New("inspect SQLite path")
}
db, err := sql.Open("sqlite3", dataSourceName(absolutePath))
if err != nil {
return nil, errors.New("initialize SQLite driver")
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, safeError{
message: "connect to SQLite",
cause: err,
}
}
return db, nil
}
func dataSourceName(absolutePath string) string {
slashPath := filepath.ToSlash(absolutePath)
if filepath.VolumeName(absolutePath) != "" &&
!strings.HasPrefix(slashPath, "/") {
slashPath = "/" + slashPath
}
dsnURL := &url.URL{
Scheme: "file",
Path: slashPath,
}
query := dsnURL.Query()
query.Set("_busy_timeout", "5000")
query.Set("_foreign_keys", "on")
query.Set("_journal_mode", "WAL")
query.Set("_txlock", "immediate")
dsnURL.RawQuery = query.Encode()
return dsnURL.String()
}
func isSafeDataSourceName(value string) bool {
lowerValue := strings.ToLower(value)
return strings.HasPrefix(lowerValue, "file:") &&
strings.Contains(lowerValue, "_busy_timeout=") &&
strings.Contains(lowerValue, "_foreign_keys=") &&
strings.Contains(lowerValue, "_journal_mode=wal") &&
strings.Contains(lowerValue, "_txlock=immediate")
}
@@ -0,0 +1,86 @@
package database
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
)
func TestOpenConfiguresSQLiteAndClosesCleanly(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "test.db")
db, err := Open(context.Background(), path)
if err != nil {
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
}
assertPragmaInt(t, db, "foreign_keys", 1)
assertPragmaInt(t, db, "busy_timeout", busyTimeoutMilliseconds)
assertPragmaString(t, db, "journal_mode", "wal")
if db.Stats().MaxOpenConnections != 1 {
t.Fatalf("MaxOpenConnections = %d", db.Stats().MaxOpenConnections)
}
if err := db.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := db.PingContext(context.Background()); err == nil {
t.Fatal("PingContext() after Close() error = nil")
}
}
func TestOpenEnforcesForeignKeys(t *testing.T) {
db, err := Open(
context.Background(),
filepath.Join(t.TempDir(), "foreign-keys.db"),
)
if err != nil {
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`
CREATE TABLE parent (id INTEGER PRIMARY KEY);
CREATE TABLE child (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL REFERENCES parent(id)
);
`); err != nil {
t.Fatalf("create tables: %v", err)
}
if _, err := db.Exec(
"INSERT INTO child (id, parent_id) VALUES (1, 999)",
); err == nil {
t.Fatal("foreign key violation error = nil")
}
}
func TestDataSourceNameIncludesRequiredOptions(t *testing.T) {
dsn := dataSourceName(filepath.Join(t.TempDir(), "test.db"))
if !isSafeDataSourceName(dsn) {
t.Fatalf("unsafe DSN options")
}
}
func assertPragmaInt(t *testing.T, db *sql.DB, name string, want int) {
t.Helper()
var got int
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
t.Fatalf("PRAGMA %s: %v", name, err)
}
if got != want {
t.Fatalf("PRAGMA %s = %d, want %d", name, got, want)
}
}
func assertPragmaString(t *testing.T, db *sql.DB, name, want string) {
t.Helper()
var got string
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
t.Fatalf("PRAGMA %s: %v", name, err)
}
if got != want {
t.Fatalf("PRAGMA %s = %q, want %q", name, got, want)
}
}
@@ -0,0 +1,57 @@
package migration
import (
"context"
"database/sql"
"cmroubao/backend-api/migrations"
"github.com/pressly/goose/v3"
)
type Status struct {
Version int64
Applied bool
}
type Runner struct {
provider *goose.Provider
}
func New(db *sql.DB) (*Runner, error) {
provider, err := goose.NewProvider(
goose.DialectSQLite3,
db,
migrations.Files,
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
return nil, err
}
return &Runner{provider: provider}, nil
}
func (r *Runner) Up(ctx context.Context) (int, error) {
results, err := r.provider.Up(ctx)
return len(results), err
}
func (r *Runner) Down(ctx context.Context) error {
_, err := r.provider.Down(ctx)
return err
}
func (r *Runner) Status(ctx context.Context) ([]Status, error) {
results, err := r.provider.Status(ctx)
if err != nil {
return nil, err
}
statuses := make([]Status, 0, len(results))
for _, result := range results {
statuses = append(statuses, Status{
Version: result.Source.Version,
Applied: result.State == goose.StateApplied,
})
}
return statuses, nil
}
@@ -0,0 +1,69 @@
package migration
import (
"context"
"path/filepath"
"testing"
"cmroubao/backend-api/internal/platform/database"
)
func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
db, err := database.Open(
context.Background(),
filepath.Join(t.TempDir(), "migration.db"),
)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })
runner, err := New(db)
if err != nil {
t.Fatalf("New() error = %v", err)
}
applied, err := runner.Up(context.Background())
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 1 {
t.Fatalf("Up() applied = %d, want 1", applied)
}
assertStatus(t, runner, true)
applied, err = runner.Up(context.Background())
if err != nil {
t.Fatalf("second Up() error = %v", err)
}
if applied != 0 {
t.Fatalf("second Up() applied = %d, want 0", applied)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down() error = %v", err)
}
assertStatus(t, runner, false)
applied, err = runner.Up(context.Background())
if err != nil {
t.Fatalf("final Up() error = %v", err)
}
if applied != 1 {
t.Fatalf("final Up() applied = %d, want 1", applied)
}
}
func assertStatus(t *testing.T, runner *Runner, applied bool) {
t.Helper()
statuses, err := runner.Status(context.Background())
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if len(statuses) != 1 {
t.Fatalf("Status() count = %d, want 1", len(statuses))
}
if statuses[0].Version != 1 || statuses[0].Applied != applied {
t.Fatalf("Status() = %+v", statuses[0])
}
}