feat: establish Go service and database foundation

This commit is contained in:
2026-08-20 19:33:38 +03:30
parent 552cae5f9d
commit 961980d18c
11 changed files with 341 additions and 1 deletions

12
.env.example Normal file
View 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
View File

@@ -13,7 +13,7 @@
/coverage/
*.test
*.out
*.exe
/vendor/
.idea/
.vscode/

18
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
}

View 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")
}
}

View 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
}

View 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
View 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;