feat: 基础框架搭建完毕
Checks / verify (push) Failing after 2m37s

This commit is contained in:
2026-10-07 09:27:47 +08:00
parent ba6dfffab7
commit bffdb3858b
64 changed files with 326 additions and 332 deletions
+112
View File
@@ -0,0 +1,112 @@
package database
import (
"context"
"crypto/sha256"
"embed"
"fmt"
"sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
)
//go:embed migrations/*.sql
var migrations embed.FS
func Open(ctx context.Context, c config.Config) (*pgxpool.Pool, error) {
if err := c.ValidateDatabase(); err != nil {
return nil, err
}
p, err := pgxpool.New(ctx, c.DatabaseURL(c.PGDatabase))
if err != nil {
return nil, fmt.Errorf("configure PostgreSQL pool")
}
if err := p.Ping(ctx); err != nil {
p.Close()
return nil, fmt.Errorf("PostgreSQL connection unavailable")
}
return p, nil
}
// Create creates only the configured database and never drops or resets data.
func Create(ctx context.Context, c config.Config) (bool, error) {
if err := c.ValidateDatabase(); err != nil {
return false, err
}
conn, err := pgx.Connect(ctx, c.DatabaseURL(c.PGAdminDatabase))
if err != nil {
return false, fmt.Errorf("administrative PostgreSQL connection unavailable")
}
defer conn.Close(context.Background())
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(hashtext($1)::bigint)", "veyra:create:"+c.PGDatabase); err != nil {
return false, fmt.Errorf("lock database creation: %w", err)
}
var exists bool
if err := conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", c.PGDatabase).Scan(&exists); err != nil {
return false, fmt.Errorf("inspect database: %w", err)
}
if exists {
return false, nil
}
if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{c.PGDatabase}.Sanitize()); err != nil {
return false, fmt.Errorf("create database failed; verify role CREATEDB permission")
}
return true, nil
}
// Migrate serializes concurrent runners and atomically records each migration.
// Even the migration ledger follows the veyra_ prefix and string UUID ID rules.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(context.Background())
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(727198321)"); err != nil {
return err
}
if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS veyra_schema_migration (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
version varchar(120) NOT NULL UNIQUE, checksum varchar(64) NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := migrations.ReadDir("migrations")
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
sql, err := migrations.ReadFile("migrations/" + entry.Name())
if err != nil {
return err
}
checksum := fmt.Sprintf("%x", sha256.Sum256(sql))
var stored string
err = tx.QueryRow(ctx, "SELECT checksum FROM veyra_schema_migration WHERE version=$1", entry.Name()).Scan(&stored)
if err == nil {
if stored != checksum {
return fmt.Errorf("migration %s checksum mismatch", entry.Name())
}
continue
}
if err != pgx.ErrNoRows {
return err
}
if _, err := tx.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("apply %s: %w", entry.Name(), err)
}
if _, err := tx.Exec(ctx, "INSERT INTO veyra_schema_migration(id,version,checksum) VALUES($1,$2,$3)", uuid.NewString(), entry.Name(), checksum); err != nil {
return err
}
}
return tx.Commit(ctx)
}
+67
View File
@@ -0,0 +1,67 @@
package database
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integration testing uses a disposable schema on an explicitly provided test database.
func TestMigrationIntegration(t *testing.T) {
dsn := os.Getenv("VEYRA_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set VEYRA_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
for i := 0; i < 2; i++ {
if err := Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
}
var count int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 {
t.Fatalf("migration ledger count=%d err=%v", count, err)
}
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES('invalid','Test')"); err == nil {
t.Fatal("invalid UUID was accepted")
}
if _, err := pool.Exec(ctx, "UPDATE veyra_schema_migration SET checksum='changed'"); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool); err == nil {
t.Fatal("modified migration checksum was accepted")
}
}
@@ -0,0 +1,18 @@
CREATE TABLE veyra_user (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
display_name varchar(80) NOT NULL CHECK (length(trim(display_name)) > 0),
status varchar(16) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'DISABLED')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE veyra_account (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
mode varchar(16) NOT NULL CHECK (mode IN ('simulation', 'manual_real')),
name varchar(80) NOT NULL CHECK (length(trim(name)) > 0),
currency varchar(3) NOT NULL DEFAULT 'CNY' CHECK (currency = 'CNY'),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, mode)
);
CREATE INDEX veyra_account_user_id_idx ON veyra_account(user_id);
@@ -0,0 +1,32 @@
ALTER TABLE veyra_user ADD COLUMN role varchar(16) NOT NULL DEFAULT 'USER' CHECK (role IN ('USER', 'ADMIN'));
CREATE TABLE veyra_credential (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_user(id),
login_name varchar(64) NOT NULL UNIQUE CHECK (login_name ~ '^[a-z0-9_]{3,64}$'),
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE veyra_session (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
token_hash varchar(64) NOT NULL UNIQUE,
audience varchar(8) NOT NULL CHECK (audience IN ('app', 'admin')),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (expires_at > created_at)
);
CREATE INDEX veyra_session_user_id_idx ON veyra_session(user_id);
ALTER TABLE veyra_account ADD COLUMN ledger_status varchar(16) NOT NULL DEFAULT 'INCOMPLETE' CHECK (ledger_status IN ('COMPLETE', 'INCOMPLETE'));
CREATE TABLE veyra_cash_entry (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
entry_type varchar(16) NOT NULL CHECK (entry_type IN ('INITIAL')),
amount_minor bigint NOT NULL CHECK (amount_minor >= 0 AND amount_minor <= 100000000000),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (account_id, entry_type)
);