Files
pangolin/server/internal/config/config.go
T
wangjia 52857d1d55 feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)
- internal/config: rewrite Config with PANGOLIN_ prefix fields
  (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr,
  AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces
  FromEnv(); missing required MySQL vars return named-field error.

- internal/store/mysql.go: single Open(cfg) entry point; uses
  mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC,
  Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts
  SELECT @@session.time_zone=+00:00 after Ping (startup fatal).

- migrations/embed.go: //go:embed *.sql exposes var FS embed.FS.

- internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion
  backed by golang-migrate iofs source + mysql driver; ErrNoChange
  treated as success.

- cmd/migrate/main.go: filled — up/down/version subcommands, reads
  config.Load() + store.Open.

- cmd/server/main.go: startup sequence Load → store.Open (UTC assert)
  → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen;
  structured slog output at each step.

- internal/store/mysql_test.go: pure-function unit tests for buildDSN
  (empty-fields case + conflicting params overridden case); both pass.
- internal/store/mysql_integration_test.go: //go:build integration;
  testcontainers mysql:8 — UTC assertion + MigrateUp idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:52:27 +08:00

108 lines
3.4 KiB
Go

// Package config loads and validates server configuration from environment
// variables and optional config files. It provides a single Config struct
// consumed by all other packages at startup.
package config
import (
"fmt"
"os"
"strconv"
)
// Config holds all application-level configuration.
// All environment variables use the PANGOLIN_ prefix (12-factor app style).
type Config struct {
// HTTPAddr is the public HTTP API listen address.
// Env: PANGOLIN_HTTP_ADDR Default: :8080
HTTPAddr string
// AdminAddr is the internal admin gRPC listen address.
// Placeholder for task #5.
// Env: PANGOLIN_ADMIN_ADDR Default: :9090
AdminAddr string
// GRPCAddr is the data-plane gRPC listen address.
// Placeholder for task #7.
// Env: PANGOLIN_GRPC_ADDR Default: :9091
GRPCAddr string
// MySQL connection. Set either MySQLDSN or the individual fields.
// If MySQLDSN is set the individual fields are ignored.
MySQLDSN string // PANGOLIN_MYSQL_DSN
MySQLHost string // PANGOLIN_MYSQL_HOST (required when no DSN)
MySQLPort string // PANGOLIN_MYSQL_PORT Default: 3306
MySQLUser string // PANGOLIN_MYSQL_USER (required when no DSN)
MySQLPassword string // PANGOLIN_MYSQL_PASSWORD
MySQLDBName string // PANGOLIN_MYSQL_DBNAME (required when no DSN)
// RedisAddr is the Redis server address.
// Env: PANGOLIN_REDIS_ADDR Default: 127.0.0.1:6379
RedisAddr string
// AutoMigrate controls whether database migrations run at startup.
// Env: PANGOLIN_AUTO_MIGRATE=true|false Default: false
AutoMigrate bool
// JWTSecret is the HS256 signing key.
// Placeholder for task #2 (auth middleware).
// Env: PANGOLIN_JWT_SECRET
JWTSecret string
// WebhookSecret is the HMAC-SHA256 shared secret for card-store webhooks.
// Env: PANGOLIN_WEBHOOK_SECRET
WebhookSecret string
}
// Load reads Config from environment variables.
// Returns an error listing any missing required variables.
func Load() (Config, error) {
c := Config{
HTTPAddr: envOr("PANGOLIN_HTTP_ADDR", ":8080"),
AdminAddr: envOr("PANGOLIN_ADMIN_ADDR", ":9090"),
GRPCAddr: envOr("PANGOLIN_GRPC_ADDR", ":9091"),
MySQLDSN: os.Getenv("PANGOLIN_MYSQL_DSN"),
MySQLHost: os.Getenv("PANGOLIN_MYSQL_HOST"),
MySQLPort: envOr("PANGOLIN_MYSQL_PORT", "3306"),
MySQLUser: os.Getenv("PANGOLIN_MYSQL_USER"),
MySQLPassword: os.Getenv("PANGOLIN_MYSQL_PASSWORD"),
MySQLDBName: os.Getenv("PANGOLIN_MYSQL_DBNAME"),
RedisAddr: envOr("PANGOLIN_REDIS_ADDR", "127.0.0.1:6379"),
JWTSecret: os.Getenv("PANGOLIN_JWT_SECRET"),
WebhookSecret: os.Getenv("PANGOLIN_WEBHOOK_SECRET"),
}
if v := os.Getenv("PANGOLIN_AUTO_MIGRATE"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return Config{}, fmt.Errorf("config: PANGOLIN_AUTO_MIGRATE=%q: %w", v, err)
}
c.AutoMigrate = b
}
// Validate: either full DSN or individual connection fields must be set.
if c.MySQLDSN == "" {
var missing []string
if c.MySQLHost == "" {
missing = append(missing, "PANGOLIN_MYSQL_HOST")
}
if c.MySQLUser == "" {
missing = append(missing, "PANGOLIN_MYSQL_USER")
}
if c.MySQLDBName == "" {
missing = append(missing, "PANGOLIN_MYSQL_DBNAME")
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("config: required env vars not set: %v (or set PANGOLIN_MYSQL_DSN)", missing)
}
}
return c, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}