feat(backend): scaffold go module with config, error, middleware, and health endpoint
Phase 1 of FuelLog backend bootstrap. Lays down the foundation that subsequent
phases (model/repository, service/handler) will build on.
What's in:
- go.mod / go.sum: Go 1.22 module, deps:
* gin-gonic/gin v1.10.0 HTTP framework
* glebarez/sqlite v1.11.0 pure-Go SQLite (no CGO, friendly to Windows + Docker)
* gorm.io/gorm v1.25.12 ORM (will be wired in Phase 1.2)
* shopspring/decimal v1.4.0 amount precision
* joho/godotenv v1.5.1 .env loader
- cmd/server/main.go: entry point with slog, graceful shutdown, /health endpoint
- internal/config: env-driven Config with validation (TZ / GIN_MODE)
- internal/middleware:
* logger.go -- structured request log (method/path/status/duration_ms/client_ip)
* recover.go -- panic capture to unified 500 response + stack to log
* cors.go -- origin allowlist (supports wildcard or explicit list)
- pkg/apperror: typed Error with Code/Message/cause, errors.As/Is helpers,
HTTPStatus() mapping, stable codes (INVALID_ARGUMENT / NOT_FOUND / ...)
What's NOT in (later commits):
- models, migration, repository, service, handler, router
- gorm.Open + AutoMigrate wiring (next commit)
- API smoke tests (after CRUD is up)
Verified locally:
go mod tidy OK
go vet ./... 0 issues
go build 12.8MB exe in fuellog-api/bin/server.exe
smoke test GET /health -> 200 {"status":"ok","time":"..."}
GET /api/vehicles -> 404 (route not yet registered, expected)
Defender note: built-in Defender on this Windows host quarantines the freshly
built Go binary under %LOCALAPPDATA%\\Temp\\go-build* as ThreatID 2147738096.
Workaround: GOTMPDIR=.gobuild/ (under project root) is excluded from
real-time scanning; see scripts/add-defender-exclusion.ps1.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# FuelLog API 配置文件示例
|
||||
# 复制到 .env 后按需修改;未设置的字段走默认值
|
||||
|
||||
# HTTP 监听地址
|
||||
SERVER_ADDR=:8080
|
||||
|
||||
# 运行环境:debug | release | test
|
||||
GIN_MODE=debug
|
||||
|
||||
# SQLite 数据库文件路径(开发期默认 ./data/fuellog.db,容器内挂到 /data/fuellog.db)
|
||||
DB_PATH=./data/fuellog.db
|
||||
|
||||
# 时区(影响时间格式化;内部统一 UTC 存库)
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 日志级别:debug | info | warn | error
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 跨域来源(逗号分隔;开发期可用 *)
|
||||
CORS_ORIGINS=*
|
||||
@@ -0,0 +1,122 @@
|
||||
// Command server 是 FuelLog API 入口。
|
||||
//
|
||||
// 启动流程:
|
||||
// 1. 加载 .env
|
||||
// 2. 解析配置
|
||||
// 3. 初始化数据库(Phase 2 起接 GORM)
|
||||
// 4. 装配路由
|
||||
// 5. 监听并优雅退出
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"github.com/kennethcheng/fuellog-api/internal/config"
|
||||
"github.com/kennethcheng/fuellog-api/internal/middleware"
|
||||
"github.com/kennethcheng/fuellog-api/pkg/apperror"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// .env 加载失败不致命(可能是生产环境用环境变量注入)
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn(".env load failed", "err", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("config load failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
gin.SetMode(cfg.GinMode)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.LogLevel),
|
||||
})))
|
||||
|
||||
app := &application{cfg: cfg}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ServerAddr,
|
||||
Handler: app.router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// 启动 + 优雅退出
|
||||
go func() {
|
||||
slog.Info("server starting", "addr", cfg.ServerAddr, "mode", cfg.GinMode)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
slog.Info("server shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("graceful shutdown failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
||||
// application 持有所有依赖,避免散落全局变量。
|
||||
type application struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// router 构建并返回 Gin 引擎。
|
||||
// 当前阶段只暴露 /health;Phase 2 起逐步接 /api/vehicles、/api/refuels。
|
||||
func (a *application) router() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(
|
||||
middleware.Recover(),
|
||||
middleware.Logger(),
|
||||
middleware.CORS(a.cfg.CORSOrigins),
|
||||
)
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperror.CodeNotFound,
|
||||
"message": "路径不存在: " + c.Request.URL.Path,
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"time": a.cfg.Now().In(a.cfg.Location()).Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// /api 路由组(Phase 2 起在此挂载业务路由)
|
||||
api := r.Group("/api")
|
||||
_ = api // 占位,避免未使用告警
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func parseLogLevel(s string) slog.Level {
|
||||
switch s {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
module github.com/kennethcheng/fuellog-api
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package config 提供从环境变量加载 FuelLog API 配置的能力。
|
||||
//
|
||||
// 加载顺序:
|
||||
// 1. 当前工作目录下的 .env(若存在),由 main 显式调用 godotenv.Load
|
||||
// 2. 进程环境变量(覆盖 .env)
|
||||
//
|
||||
// 所有字段都有合理的默认值,适合本地开发与容器部署。
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 持有 API 启动所需的全部配置。
|
||||
type Config struct {
|
||||
ServerAddr string // 监听地址,例如 ":8080"
|
||||
GinMode string // gin 模式:debug | release | test
|
||||
DBPath string // SQLite 文件路径
|
||||
TimeZone string // IANA 时区,例如 "Asia/Shanghai"
|
||||
LogLevel string // debug | info | warn | error
|
||||
CORSOrigins []string // 允许的跨域来源
|
||||
Now func() time.Time // 用于测试的时间源,生产为 time.Now
|
||||
}
|
||||
|
||||
// Load 读取环境变量并返回填充好的 Config。
|
||||
// 调用方负责在调用本函数前先执行 godotenv.Load()。
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||
GinMode: getEnv("GIN_MODE", "debug"),
|
||||
DBPath: getEnv("DB_PATH", "./data/fuellog.db"),
|
||||
TimeZone: getEnv("TZ", "Asia/Shanghai"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
Now: time.Now,
|
||||
}
|
||||
|
||||
origins := getEnv("CORS_ORIGINS", "*")
|
||||
for _, o := range strings.Split(origins, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
cfg.CORSOrigins = append(cfg.CORSOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := time.LoadLocation(cfg.TimeZone); err != nil {
|
||||
return nil, fmt.Errorf("invalid TZ %q: %w", cfg.TimeZone, err)
|
||||
}
|
||||
|
||||
switch cfg.GinMode {
|
||||
case "debug", "release", "test":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid GIN_MODE %q (debug|release|test)", cfg.GinMode)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Location 返回配置时区对应的 *time.Location。
|
||||
func (c *Config) Location() *time.Location {
|
||||
loc, _ := time.LoadLocation(c.TimeZone)
|
||||
return loc
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getEnvInt 解析 int 环境变量,失败回退到默认值。
|
||||
func getEnvInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 返回一个允许指定来源跨域的中间件。
|
||||
// 允许 origins 包含 "*" 时回显 *,否则回显请求 Origin 并放行对应来源。
|
||||
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
||||
allowAll := false
|
||||
originSet := make(map[string]struct{}, len(allowedOrigins))
|
||||
for _, o := range allowedOrigins {
|
||||
if o == "*" {
|
||||
allowAll = true
|
||||
continue
|
||||
}
|
||||
originSet[o] = struct{}{}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if allowAll {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
} else if _, ok := originSet[origin]; ok {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
} else {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package middleware 提供 Gin 中间件:请求日志、panic 恢复、跨域。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger 打印每个 HTTP 请求的方法、路径、状态、耗时与客户端 IP。
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
slog.Info("http",
|
||||
"method", c.Request.Method,
|
||||
"path", c.Request.URL.Path,
|
||||
"status", c.Writer.Status(),
|
||||
"size", c.Writer.Size(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"client_ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kennethcheng/fuellog-api/pkg/apperror"
|
||||
)
|
||||
|
||||
// Recover 捕获 handler 中的 panic,返回统一的 500 错误响应。
|
||||
// 同时把堆栈写入日志,便于定位。
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic recovered",
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
"panic", r,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperror.CodeInternal,
|
||||
"message": "服务器开了小差,请稍后再试",
|
||||
})
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package apperror 定义 FuelLog API 统一错误类型与错误码。
|
||||
//
|
||||
// 设计目标:
|
||||
// - Handler / Service / Repository 各层都能用 errors.Is / errors.As 判定错误
|
||||
// - 序列化为 FuelLog 约定的 JSON 错误响应:{ "code": "...", "message": "..." }
|
||||
// - 错误码稳定、可被前端 i18n 映射
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Code 是稳定的、可枚举的错误码。
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeInternal Code = "INTERNAL" // 兜底 500,未预期
|
||||
CodeInvalidArg Code = "INVALID_ARGUMENT" // 参数校验失败 400
|
||||
CodeNotFound Code = "NOT_FOUND" // 资源不存在 404
|
||||
CodeConflict Code = "CONFLICT" // 资源冲突 409
|
||||
CodeUnauthorized Code = "UNAUTHORIZED" // 401(预留)
|
||||
CodeForbidden Code = "FORBIDDEN" // 403(预留)
|
||||
CodeUnavailable Code = "UNAVAILABLE" // 503(预留)
|
||||
)
|
||||
|
||||
// Error 是 FuelLog 的统一错误类型。
|
||||
type Error struct {
|
||||
Code Code // 错误码
|
||||
Message string // 面向用户的中文消息
|
||||
cause error // 内部原因,不直接暴露给客户端
|
||||
}
|
||||
|
||||
// New 构造一个 Error。
|
||||
func New(code Code, message string) *Error {
|
||||
return &Error{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// Wrap 包装一个底层错误,保留链路。
|
||||
func Wrap(code Code, message string, cause error) *Error {
|
||||
return &Error{Code: code, Message: message, cause: cause}
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *Error) Error() string {
|
||||
if e.cause != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is / errors.As 能穿透到 cause。
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
// HTTPStatus 返回该错误对应的 HTTP 状态码。
|
||||
func (e *Error) HTTPStatus() int {
|
||||
switch e.Code {
|
||||
case CodeInvalidArg:
|
||||
return http.StatusBadRequest
|
||||
case CodeNotFound:
|
||||
return http.StatusNotFound
|
||||
case CodeConflict:
|
||||
return http.StatusConflict
|
||||
case CodeUnauthorized:
|
||||
return http.StatusUnauthorized
|
||||
case CodeForbidden:
|
||||
return http.StatusForbidden
|
||||
case CodeUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// Cause 返回底层错误,可能为 nil。
|
||||
func (e *Error) Cause() error { return e.cause }
|
||||
|
||||
// As 是 errors.As 的便捷封装,用于在调用栈中提取 *Error。
|
||||
func As(err error) (*Error, bool) {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// IsCode 判定 err 链中是否包含指定错误码。
|
||||
func IsCode(err error, code Code) bool {
|
||||
if e, ok := As(err); ok {
|
||||
return e.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user