feat: Go rewrite M1 — skeleton, tooling, and hello server
Stand up the Go project alongside the Python backend so both run
independently during migration. `make web-go` builds and serves on :8080;
`make web-py` (alias: `make web`) keeps the Python side on :5001.
- go/: new module `fuj-management/go` (Go 1.26)
- cmd/fuj: stdlib-flag dispatcher; `server` + `version` work,
fees/reconcile/sync/infer stubbed for M2/M4
- internal/config: env loader mirroring scripts/config.py
- internal/logging: slog setup, level taken from config
- internal/web: net/http ServeMux + request-timer middleware
- build/Dockerfile: golang:1.26 → alpine:3 multi-stage image
- .golangci.yml: govet, staticcheck, errcheck, gofumpt, unused
- Makefile: web→web-py alias; go-build/go-test/go-run/go-lint/web-go
- CI: parallel build-go job in .gitea/workflows/build.yaml (<tag>-go image)
- docs/plans/: M1 kickoff plan + progress tracker (M1 complete)
- .claude/settings.json: gofumpt + golangci-lint permissions
Gate: make go-build ✓ make go-lint ✓ make go-test ✓ curl :8080 ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
11
go/.golangci.yml
Normal file
11
go/.golangci.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- gofumpt
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
30
go/build/Dockerfile
Normal file
30
go/build/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM golang:1.26 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG GIT_TAG=unknown
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w \
|
||||
-X main.version=${GIT_TAG} \
|
||||
-X main.commit=${GIT_COMMIT} \
|
||||
-X main.buildDate=${BUILD_DATE}" \
|
||||
-o /out/fuj ./cmd/fuj
|
||||
|
||||
FROM alpine:3
|
||||
|
||||
RUN addgroup -S fuj && adduser -S fuj -G fuj
|
||||
|
||||
COPY --from=build /out/fuj /usr/local/bin/fuj
|
||||
|
||||
EXPOSE 8080
|
||||
USER fuj
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/fuj", "server"]
|
||||
84
go/cmd/fuj/main.go
Normal file
84
go/cmd/fuj/main.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/logging"
|
||||
"fuj-management/go/internal/web"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
buildDate = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cmd, args := os.Args[1], os.Args[2:]
|
||||
|
||||
switch cmd {
|
||||
case "server":
|
||||
serverCmd(args)
|
||||
case "version":
|
||||
versionCmd()
|
||||
case "fees", "reconcile", "sync", "infer":
|
||||
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M2/M4)\n", cmd)
|
||||
os.Exit(2)
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "fuj: unknown command %q\n\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func serverCmd(args []string) {
|
||||
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||
addr := fs.String("addr", "", "listen address (default from SERVER_ADDR env or :8080)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "usage: fuj server [--addr :8080]")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
if *addr != "" {
|
||||
cfg.ServerAddr = *addr
|
||||
}
|
||||
|
||||
logger := logging.New(cfg.LogLevel)
|
||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||
|
||||
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd() {
|
||||
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `usage: fuj <command> [flags]
|
||||
|
||||
Commands:
|
||||
server Start HTTP server (default :8080)
|
||||
version Print version information
|
||||
fees Calculate monthly fees [M2]
|
||||
reconcile Show balance report [M2]
|
||||
sync Sync Fio transactions [M4]
|
||||
infer Infer payment details [M4]`)
|
||||
}
|
||||
56
go/internal/config/config.go
Normal file
56
go/internal/config/config.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Google Sheets IDs — change in code if sheets change (not from env).
|
||||
const (
|
||||
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||
PaymentsSheetID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||
JuniorSheetGID = "1213318614"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration loaded from environment variables.
|
||||
// Mirrors scripts/config.py.
|
||||
type Config struct {
|
||||
CredentialsPath string
|
||||
BankAccount string
|
||||
CacheTTL time.Duration
|
||||
CacheAPICheckTTL time.Duration
|
||||
LogLevel string
|
||||
FioAPIToken string
|
||||
ServerAddr string
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults that
|
||||
// match the Python side.
|
||||
func Load() Config {
|
||||
return Config{
|
||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||
LogLevel: env("LOG_LEVEL", "INFO"),
|
||||
FioAPIToken: env("FIO_API_TOKEN", ""),
|
||||
ServerAddr: env("SERVER_ADDR", ":8080"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDuration(key string, defaultSeconds int) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
return time.Duration(defaultSeconds) * time.Second
|
||||
}
|
||||
24
go/internal/logging/logger.go
Normal file
24
go/internal/logging/logger.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New returns a slog.Logger at the given level (DEBUG|INFO|WARN|ERROR).
|
||||
// Pass config.Config.LogLevel as the argument. Defaults to INFO on unrecognised input.
|
||||
func New(level string) *slog.Logger {
|
||||
var l slog.Level
|
||||
switch strings.ToUpper(level) {
|
||||
case "DEBUG":
|
||||
l = slog.LevelDebug
|
||||
case "WARN", "WARNING":
|
||||
l = slog.LevelWarn
|
||||
case "ERROR":
|
||||
l = slog.LevelError
|
||||
default:
|
||||
l = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}))
|
||||
}
|
||||
34
go/internal/web/middleware/timer.go
Normal file
34
go/internal/web/middleware/timer.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (sw *statusWriter) WriteHeader(code int) {
|
||||
sw.status = code
|
||||
sw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// RequestTimer logs method, path, status, and elapsed milliseconds for every
|
||||
// request. Parity with Python's get_render_time — the elapsed value maps to
|
||||
// render_time.total in the M5 JSON allowlist.
|
||||
func RequestTimer(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
logger.Info("req",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
32
go/internal/web/server.go
Normal file
32
go/internal/web/server.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// BuildInfo carries the linker-injected build metadata.
|
||||
type BuildInfo struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
}
|
||||
|
||||
// Run registers routes and starts the HTTP server on addr.
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
||||
|
||||
logger.Info("starting server", "addr", addr)
|
||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||
}
|
||||
|
||||
func helloHandler(build BuildInfo) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
|
||||
build.Version, build.Commit, build.BuildDate)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user