feat: establish Go service and database foundation
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Local development only. Copy to .env and replace secrets; .env is ignored.
|
||||
APP_ENV=development
|
||||
HTTP_ADDR=127.0.0.1:8080
|
||||
DATABASE_URL=postgresql://postgres:change-me@localhost:5432/zarinpal?sslmode=disable
|
||||
REDIS_ADDR=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
IMPORT_PATH=data/incoming/challenge_data.csv.gz
|
||||
DEMO_USERNAME=merchant-0001
|
||||
DEMO_PASSWORD=replace-with-at-least-12-characters
|
||||
SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
COOKIE_SECURE=false
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@
|
||||
/coverage/
|
||||
*.test
|
||||
*.out
|
||||
*.exe
|
||||
/vendor/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
|
||||
18
Makefile
Normal file
18
Makefile
Normal file
@@ -0,0 +1,18 @@
|
||||
.PHONY: test vet build migrate import seed api worker
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
vet:
|
||||
go vet ./...
|
||||
build:
|
||||
go build ./cmd/...
|
||||
migrate:
|
||||
go run ./cmd/migrate
|
||||
import:
|
||||
go run ./cmd/importer
|
||||
seed:
|
||||
go run ./cmd/admin
|
||||
api:
|
||||
go run ./cmd/api
|
||||
worker:
|
||||
go run ./cmd/worker
|
||||
38
cmd/migrate/main.go
Normal file
38
cmd/migrate/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
migration, err := os.ReadFile("migrations/000001_mvp.sql")
|
||||
if err != nil {
|
||||
logger.Error("reading migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := database.Migrate(ctx, pool, string(migration)); err != nil {
|
||||
logger.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migration complete", "version", 1)
|
||||
}
|
||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.7.5
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
28
go.sum
Normal file
28
go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
66
internal/config/config.go
Normal file
66
internal/config/config.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
ImportPath string
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
RedisAddr: value("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
ImportPath: value("IMPORT_PATH", "data/incoming/challenge_data.csv.gz"),
|
||||
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
|
||||
DemoPassword: os.Getenv("DEMO_PASSWORD"),
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
|
||||
}
|
||||
|
||||
var err error
|
||||
cfg.CookieSecure, err = strconv.ParseBool(value("COOKIE_SECURE", "false"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parsing cookie secure: %w", err)
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, errors.New("config: DATABASE_URL is required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) ValidateAPI() error {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) ValidateSeed() error {
|
||||
if len(c.DemoPassword) < 12 {
|
||||
return errors.New("config: DEMO_PASSWORD must contain at least 12 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
18
internal/config/config_test.go
Normal file
18
internal/config/config_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadRequiresDatabaseURL(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load() error = nil, want required database url error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresLongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "short"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want secret length error")
|
||||
}
|
||||
}
|
||||
30
internal/database/database.go
Normal file
30
internal/database/database.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing database configuration: %w", err)
|
||||
}
|
||||
poolConfig.MaxConns = 12
|
||||
poolConfig.MinConns = 1
|
||||
poolConfig.MaxConnLifetime = 30 * time.Minute
|
||||
poolConfig.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
23
internal/database/migrate.go
Normal file
23
internal/database/migrate.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Migrate applies the idempotent local MVP schema. Production migration tooling
|
||||
// can execute the same reviewed SQL from migrations/.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool, migration string) error {
|
||||
for statement := range strings.SplitSeq(migration, "-- statement-break") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := pool.Exec(ctx, statement); err != nil {
|
||||
return fmt.Errorf("applying migration: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
93
migrations/000001_mvp.sql
Normal file
93
migrations/000001_mvp.sql
Normal file
@@ -0,0 +1,93 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version bigint PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS import_runs (
|
||||
id bigserial PRIMARY KEY,
|
||||
source_sha256 text NOT NULL UNIQUE,
|
||||
source_path text NOT NULL,
|
||||
status text NOT NULL CHECK (status IN ('RUNNING','COMPLETED','FAILED')),
|
||||
source_rows bigint NOT NULL DEFAULT 0,
|
||||
session_rows bigint NOT NULL DEFAULT 0,
|
||||
attempt_rows bigint NOT NULL DEFAULT 0,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS merchants (
|
||||
id bigserial PRIMARY KEY,
|
||||
source_key text NOT NULL UNIQUE,
|
||||
category_id bigint NOT NULL,
|
||||
category_title text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
profile_is_synthetic boolean NOT NULL DEFAULT true
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS terminals (
|
||||
id bigserial PRIMARY KEY,
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
source_key text NOT NULL,
|
||||
verify_type text NOT NULL,
|
||||
UNIQUE (merchant_id, source_key)
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS payment_sessions (
|
||||
id bigserial PRIMARY KEY,
|
||||
import_run_id bigint NOT NULL REFERENCES import_runs(id),
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
terminal_id bigint NOT NULL REFERENCES terminals(id),
|
||||
source_key text NOT NULL,
|
||||
status text NOT NULL,
|
||||
amount_rial bigint NOT NULL CHECK (amount_rial >= 0),
|
||||
fee_amount_rial bigint NOT NULL CHECK (fee_amount_rial >= 0),
|
||||
payer_card_key text,
|
||||
created_at timestamptz NOT NULL,
|
||||
expires_at timestamptz,
|
||||
verified_at timestamptz,
|
||||
settled_at timestamptz,
|
||||
UNIQUE (merchant_id, source_key)
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS payment_attempts (
|
||||
id bigserial PRIMARY KEY,
|
||||
session_id bigint NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE,
|
||||
sequence integer NOT NULL CHECK (sequence > 0),
|
||||
status text NOT NULL,
|
||||
created_at timestamptz,
|
||||
init_time_ms integer,
|
||||
verify_time_ms integer,
|
||||
UNIQUE (session_id, sequence)
|
||||
);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS payment_sessions_merchant_created_idx ON payment_sessions (merchant_id, created_at DESC, id DESC);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS payment_sessions_status_idx ON payment_sessions (merchant_id, status, created_at DESC);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS payment_attempts_session_idx ON payment_attempts (session_id, sequence);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS merchant_users (
|
||||
id bigserial PRIMARY KEY,
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES merchant_users(id),
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
csrf_token text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
idle_expires_at timestamptz NOT NULL,
|
||||
absolute_expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
last_seen_at timestamptz NOT NULL
|
||||
);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS auth_sessions_token_idx ON auth_sessions (token_hash) WHERE revoked_at IS NULL;
|
||||
-- statement-break
|
||||
INSERT INTO schema_migrations(version) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user