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/
|
/coverage/
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
*.exe
|
||||||
/vendor/
|
/vendor/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.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