commit c400394ed678be705cf33a4c52a757916b0cb2e6 Author: noelorin Date: Tue Aug 25 12:12:18 2026 +0800 feat: initial commit - StreamBox Go+Gin framework diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b882e48 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=8080 +GIN_MODE=debug diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d7d806 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +vendor/ +.env +*.log +.DS_Store +.idea/ +.vscode/ +tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..589388f --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: run build tidy fmt lint clean + +APP_NAME=streambox +MAIN=./cmd/server + +run: + go run $(MAIN) + +build: + go build -o bin/$(APP_NAME) $(MAIN) + +tidy: + go mod tidy + +fmt: + go fmt ./... + +clean: + rm -rf bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbd80dd --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# StreamBox + +Go + Gin 项目框架 + +## 目录结构 + +``` +. +├── cmd/server # 程序入口 +├── config # 配置文件 +├── internal +│ ├── handler # HTTP 处理层 +│ ├── service # 业务逻辑层 +│ ├── model # 数据模型 +│ ├── middleware # 中间件 +│ └── router # 路由注册 +├── pkg +│ ├── logger # Zap 日志封装 +│ └── response # 统一响应封装 +├── api # API 文档 +└── docs # 项目文档 +``` + +## 快速开始 + +```bash +# 安装依赖 +make tidy + +# 启动服务 +make run +# 或 +# go run ./cmd/server + +# 打包 +make build +./bin/streambox +``` + +## 配置 + +- `config/config.yaml` 主配置 +- 环境变量优先于配置文件,通过 `viper.AutomaticEnv` 覆盖 + +## 路由 + +- `GET /health` 健康检查 +- `GET /api/v1/ping` 示例接口 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1aae048 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "streambox/config" + "streambox/internal/router" + "streambox/pkg/logger" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config failed: %v", err) + } + if err := logger.Init(cfg.Log.Level, cfg.Log.Encoding); err != nil { + log.Fatalf("init logger failed: %v", err) + } + defer logger.Sync() + + r := router.New(cfg) + + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: r, + } + // 可选超时 + if d, err := time.ParseDuration(cfg.Server.ReadTimeout); err == nil { + srv.ReadTimeout = d + } + if d, err := time.ParseDuration(cfg.Server.WriteTimeout); err == nil { + srv.WriteTimeout = d + } + + go func() { + logger.Sugar.Infow("server starting", "addr", addr, "mode", cfg.Server.Mode) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen failed: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + logger.Sugar.Info("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.Sugar.Errorf("server shutdown failed: %v", err) + } + logger.Sugar.Info("server exited") +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..f4f00ad --- /dev/null +++ b/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "strings" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Log LogConfig `mapstructure:"log"` + Cors CorsConfig `mapstructure:"cors"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` + ReadTimeout string `mapstructure:"read_timeout"` + WriteTimeout string `mapstructure:"write_timeout"` +} + +type LogConfig struct { + Level string `mapstructure:"level"` + Encoding string `mapstructure:"encoding"` +} + +type CorsConfig struct { + AllowOrigins []string `mapstructure:"allow_origins"` + AllowMethods []string `mapstructure:"allow_methods"` + AllowHeaders []string `mapstructure:"allow_headers"` +} + +var C *Config + +func Load() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath("./config") + viper.AddConfigPath(".") + + viper.SetDefault("server.port", 8080) + viper.SetDefault("server.mode", "debug") + viper.SetDefault("log.level", "debug") + viper.SetDefault("log.encoding", "console") + + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // 允许 PORT 等环境变量直接覆盖 + _ = viper.BindEnv("server.port", "PORT") + + if err := viper.ReadInConfig(); err != nil { + // 配置文件不存在时使用默认值+环境变量,不视为致命错误 + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + C = &cfg + return &cfg, nil +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..62ef037 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,14 @@ +server: + port: 8080 + mode: debug # debug / release + read_timeout: 10s + write_timeout: 10s + +log: + level: debug # debug / info / warn / error + encoding: console # console / json + +cors: + allow_origins: ["*"] + allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_headers: ["Origin", "Content-Type", "Authorization"] diff --git a/docs/01-go-环境与基础语法.md b/docs/01-go-环境与基础语法.md new file mode 100644 index 0000000..fe9c081 --- /dev/null +++ b/docs/01-go-环境与基础语法.md @@ -0,0 +1,323 @@ +# 第 1 章 Go 环境与基础语法 + +> 学完本章你将:装好 Go、理解 go mod、掌握变量/类型/流程/函数/结构体/接口/错误处理,能读懂本项目 90% 的非并发代码。 + +## 1.1 安装与环境 + +### 安装 + +- 官网 https://go.dev/dl 下载对应系统安装包 +- 验证:`go version` 应输出 `go1.26.x` +- 本项目要求 `go 1.26.2`(见 `go.mod` 第一行) + +### 环境变量 + +```bash +go env GOPATH GOROOT GOPROXY +# GOPATH Workspace 目录(Go 1.11 后已弱化,保留兼容) +# GOROOT Go 安装目录 +# GOPROXY 代理,国内建议: +go env -w GOPROXY=https://goproxy.cn,direct +go env -w GOPRIVATE="" +``` + +### 编辑器 + +推荐 VS Code + Go 插件(gopls),或 GoLand。安装后执行 `go install golang.org/x/tools/gopls@latest`。 + +## 1.2 go mod 模块管理 + +Go 1.11+ 用 `go.mod` 管理依赖,替代 GOPATH。本项目 `go.mod` 开头: + +```go +module streambox +go 1.26.2 +``` + +常用命令(对应本项目 `Makefile`): + +```bash +go mod init streambox # 初始化(已存在无需重复) +go mod tidy # 整理依赖,对应 make tidy +go get github.com/gin-gonic/gin@latest # 添加/升级依赖 +go list -m all # 查看所有依赖 +go mod why github.com/gin-gonic/gin # 查询谁依赖它 +``` + +`go.sum` 是依赖校验和,提交到 Git,不要手改。 + +## 1.3 Hello World + +创建 `main.go`: + +```go +package main + +import "fmt" + +func main() { + fmt.Println("hello, streambox") +} +``` + +运行 `go run main.go`。`package main` + `func main()` 是可执行程序入口。 + +## 1.4 变量与常量 + +```go +// 显式声明 +var name string = "streambox" +var port int = 8080 +var debug bool + +// 类型推导 +var addr = ":8080" + +// 短声明(函数内常用) +count := 42 +msg := "ok" + +// 批量声明 +var ( + host = "0.0.0.0" + timeout = 10 +) + +// 常量 +const AppName = "streambox" +const ( + ModeDebug = "debug" + ModeRelease = "release" +) + +// iota 枚举 +const ( + CodeSuccess = iota // 0 + CodeBadRequest // 1 + CodeInternal // 2 +) +``` + +零值:`int` 为 0,`string` 为 "",`bool` 为 false,指针/slice/map/channel/interface 为 nil。 + +## 1.5 基础类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| `bool` | 布尔 | `true/false` | +| `int/int8/int16/int32/int64` | 有符号整型 | `int` 随平台 32/64 位 | +| `uint/...` | 无符号整型 | `uint port = 8080` | +| `float32/float64` | 浮点 | `float64` 常用 | +| `string` | 字符串,不可变,UTF-8 | `"hello"` | +| `byte` (alias uint8) | 字节 | `'a'` | +| `rune` (alias int32) | Unicode 码点 | `'中'` | + +字符串操作: + +```go +s := "hello" +len(s) // 字节长度 5 +s[1] // byte 'e' +for _, r := range s { // 按 rune 遍历 + fmt.Printf("%c", r) +} +s + " world" // 拼接 +fmt.Sprintf("port=%d", 8080) +``` + +## 1.6 复合类型:数组、切片、Map + +```go +// 数组(定长,很少直接用) +var a [3]int = [3]int{1,2,3} + +// 切片(动态数组,最常用) +s := []int{1, 2, 3} +s = append(s, 4) +sub := s[1:3] // [2,3] 左闭右开 +s2 := make([]int, 0, 8) // len 0 cap 8 + +// Map +m := map[string]int{"a": 1} +m["b"] = 2 +v, ok := m["c"] // ok 判断是否存在 +delete(m, "a") +for k, v := range m { fmt.Println(k, v) } +m2 := make(map[string]string) +``` + +注意:slice/map 作为参数传递时是引用语义;nil slice 可 append,nil map 不可写入。 + +## 1.7 流程控制 + +```go +// if +if err := do(); err != nil { + log.Fatal(err) +} else { + fmt.Println("ok") +} + +// for 只有 for +for i := 0; i < 3; i++ {} +for range s {} +for { break } // 死循环 + +// switch +switch mode { +case "debug": + fmt.Println("debug") +case "release": + fmt.Println("release") +default: + fmt.Println("unknown") +} + +// switch 带表达式 +switch { +case port < 1024: + fmt.Println("privileged") +default: + fmt.Println("ok") +} + +// defer 延迟执行(LIFO),常用于资源释放 +f, _ := os.Open("config.yaml") +defer f.Close() +defer logger.Sync() // 本项目 main.go 中就有 +``` + +## 1.8 函数 + +```go +// 普通函数 +func Add(a, b int) int { return a + b } + +// 多返回值(Go 错误处理的核心) +func Divide(a, b int) (int, error) { + if b == 0 { + return 0, fmt.Errorf("divide by zero") + } + return a / b, nil +} + +// 命名返回值 +func Split(s string) (first, second string) { + // ... + return +} + +// 可变参数 +func Sum(nums ...int) int { + n := 0 + for _, v := range nums { n += v } + return n +} + +// 函数作为值 +fn := func(s string) string { return s + "!" } +type HandlerFunc func(*gin.Context) // Gin 中大量使用 +``` + +## 1.9 结构体与方法 + +```go +// 定义 +type ServerConfig struct { + Port int `yaml:"port" mapstructure:"port"` + Mode string `yaml:"mode"` + ReadTimeout string `yaml:"read_timeout"` + WriteTimeout string `yaml:"write_timeout"` +} + +// 方法(接收者为值或指针) +func (c ServerConfig) Addr() string { + return fmt.Sprintf(":%d", c.Port) +} +func (c *ServerConfig) SetPort(p int) { c.Port = p } + +// 初始化 +cfg := ServerConfig{Port: 8080, Mode: "debug"} +cfg2 := &ServerConfig{Port: 8080} // 指针 + +// 嵌入(组合,非继承) +type Config struct { + Server ServerConfig `mapstructure:"server"` + Log LogConfig +} +``` + +本项目 `config/config.go` 就是典型的结构体 + viper 绑定。 + +## 1.10 接口 + +接口是 Go 多态的核心,隐式实现: + +```go +type Pinger interface { + Ping() string +} + +// 只要实现了 Ping() string,就实现了 Pinger,无需显式声明 +type PingService struct{} +func (s *PingService) Ping() string { return "pong" } + +var _ Pinger = (*PingService)(nil) // 编译期校验 + +// 空接口 interface{}(Go 1.18 后别名 any)可表示任意类型 +var x any = 42 +x = "hello" + +// 类型断言 +if s, ok := x.(string); ok { fmt.Println(s) } + +// type switch +switch v := x.(type) { +case string: fmt.Println("string", v) +case int: fmt.Println("int", v) +} +``` + +Gin 的 `gin.HandlerFunc` 本质是 `func(*gin.Context)`,也是一种函数类型接口。 + +## 1.11 错误处理 + +Go 无异常,错误即值: + +```go +f, err := os.Open("config.yaml") +if err != nil { + return fmt.Errorf("open config: %w", err) // %w 包装,支持 errors.Is/As +} +defer f.Close() + +// 自定义错误 +type AppError struct { + Code int + Msg string +} +func (e *AppError) Error() string { return e.Msg } + +// 判断 +if errors.Is(err, os.ErrNotExist) { ... } +var appErr *AppError +if errors.As(err, &appErr) { fmt.Println(appErr.Code) } + +// panic/recover 仅用于不可恢复错误,业务错误不要用 +// 本项目 middleware/recovery.go 用 recover 捕获 panic,避免进程崩溃 +``` + +## 1.12 包与可见性 + +- 同目录同 `package` 名 +- 大写开头导出(Public),小写私有:`logger.Log` 可导出,`join` 不可(见 `middleware/cors.go`) +- 导入:`import "streambox/pkg/logger"`,`import gin "github.com/gin-gonic/gin"` + +## 1.13 动手练习 + +1. 编写 `fib(n int) int` 递归与迭代两版。 +2. 定义 `User` 结构体,实现 `String() string` 方法。 +3. 模仿 `pkg/response` 封装 `Result`,编写 `Success`/`Fail` 并用 `encoding/json` 序列化验证。 + +下一章将学习并发与工程化。 + diff --git a/docs/02-go-进阶-并发与工程化.md b/docs/02-go-进阶-并发与工程化.md new file mode 100644 index 0000000..22f5064 --- /dev/null +++ b/docs/02-go-进阶-并发与工程化.md @@ -0,0 +1,199 @@ +# 第 2 章 Go 进阶:并发与工程化 + +> 学完本章你将:用 goroutine/channel 写并发、用 context 控制生命周期、用 sync 解决共享状态、会写测试并掌握常用工具链。 + +## 2.1 goroutine 轻量线程 + +Go 并发的最小单元是 `goroutine`,由运行时调度,创建成本约几 KB。 + +```go +// 启动一个 goroutine +go func() { + fmt.Println("in goroutine") +}() + +// 本项目 cmd/server/main.go 启动 HTTP 服务就是 goroutine +go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen failed: %v", err) + } +}() +``` + +主 goroutine 结束则程序退出,所以 `main` 中用 `signal.Notify` 阻塞等待退出信号。 + +## 2.2 channel 通道 + +channel 是 goroutine 间通信的管道,语义是“不要通过共享内存来通信,通过通信来共享内存”。 + +```go +// 无缓冲:发送阻塞直到有人接收 +ch := make(chan string) +go func() { ch <- "pong" }() +msg := <-ch // 接收 + +// 有缓冲 +ch2 := make(chan int, 2) +ch2 <- 1 +ch2 <- 2 +// ch2 <- 3 // 阻塞,缓冲满 + +// 关闭与遍历 +close(ch2) +for v := range ch2 { fmt.Println(v) } + +// 单向 channel(函数签名约束) +func producer(ch chan<- int) { ch <- 42 } // 只发 +func consumer(ch <-chan int) { fmt.Println(<-ch) } // 只收 + +// select 多路复用 +select { +case v := <-ch: + fmt.Println(v) +case <-time.After(2 * time.Second): + fmt.Println("timeout") +} +``` + +典型模式:用 channel 做信号通知,本项目 `quit := make(chan os.Signal, 1)` 就是 buffered channel 接收系统信号。 + +## 2.3 context 上下文 + +`context` 用于传递取消信号、超时和请求级数据,Gin 的 `c.Request.Context()` 即标准 context。 + +```go +// 超时控制 +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +// 本项目优雅关闭 +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +srv.Shutdown(ctx) + +// 取消 +ctx, cancel := context.WithCancel(context.Background()) +go func() { + <-ctx.Done() + fmt.Println("canceled:", ctx.Err()) +}() +cancel() + +// 传值(仅放请求级数据如 traceID,不要放业务大对象) +ctx = context.WithValue(ctx, "userID", 123) +``` + +规则:context 作为函数第一个参数,命名 `ctx`,不要存入结构体。 + +## 2.4 sync 同步原语 + +### Mutex 互斥锁 + +```go +var mu sync.Mutex +var count int +mu.Lock() +count++ +mu.Unlock() + +// RWMutex 读写锁 +var rw sync.RWMutex +rw.RLock(); v := data; rw.RUnlock() +rw.Lock(); data = newVal; rw.Unlock() +``` + +### WaitGroup 等待组 + +```go +var wg sync.WaitGroup +for i := 0; i < 3; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + fmt.Println(n) + }(i) +} +wg.Wait() +``` + +### Once 单次执行 + +```go +var once sync.Once +var inst *Service +once.Do(func() { inst = &Service{} }) +``` + +本项目 `logger.Init` 虽未用 Once,但生产中常用来保证日志单例初始化一次。 + +## 2.5 并发模式示例 + +```go +// Worker Pool +jobs := make(chan int, 10) +results := make(chan int, 10) + +for w := 0; w < 3; w++ { + go func() { + for j := range jobs { results <- j * 2 } + }() +} +for i := 0; i < 5; i++ { jobs <- i } +close(jobs) +``` + +## 2.6 错误与 panic 边界 + +- 业务错误用 `error` 返回 +- 不可恢复错误才 `panic`,并在最外层 `recover`(见 `middleware/recovery.go`) +- goroutine 内的 panic 不会被外层 recover 捕获,需在 goroutine 内部 recover + +## 2.7 测试 + +```go +// ping_test.go +package service + +import "testing" + +func TestPing(t *testing.T) { + svc := NewPingService(nil) + if got := svc.Ping(); got != "pong" { + t.Fatalf("want pong got %s", got) + } +} + +// 表驱动测试 +func TestAdd(t *testing.T) { + cases := []struct{ a, b, want int }{{1,2,3},{0,0,0}} + for _, c := range cases { + if got := Add(c.a, c.b); got != c.want { + t.Errorf("Add(%d,%d)=%d want %d", c.a, c.b, got, c.want) + } + } +} +``` + +```bash +go test ./... +go test -run TestPing -v ./internal/service +go test -race ./... # 检测数据竞争 +go test -cover ./... # 覆盖率 +``` + +## 2.8 工具链 + +| 命令 | 用途 | +|------|------| +| `go fmt ./...` | 格式化,对应 `make fmt` | +| `go vet ./...` | 静态检查 | +| `golangci-lint run` | 综合 lint(需安装) | +| `go mod tidy` | 整理依赖,对应 `make tidy` | +| `go run ./cmd/server` | 运行,对应 `make run` | +| `go build -o bin/streambox ./cmd/server` | 编译,对应 `make build` | + +## 2.9 动手练习 + +1. 用 goroutine + channel 实现并发求 1..100 的和,对比串行版本。 +2. 用 `context.WithTimeout` 实现一个 2 秒超时的 HTTP 请求。 +3. 给 `internal/service/ping.go` 补充单测并用 `go test -race` 验证。 diff --git a/docs/03-gin-入门.md b/docs/03-gin-入门.md new file mode 100644 index 0000000..30dd1a4 --- /dev/null +++ b/docs/03-gin-入门.md @@ -0,0 +1,108 @@ +# 第 3 章 Gin 入门 + +> 学完本章你将:理解 Gin 的定位与优势、跑通第一个 Gin 服务、看懂 Gin 与标准库 net/http 的关系。 + +## 3.1 为什么选 Gin + +- **快**:基于 httprouter 的前缀树路由,性能居 Go Web 框架前列 +- **简洁**:`gin.Default()` 一行起服务,`c.JSON` 一行返回 +- **生态**:中间件、绑定、验证、分组等开箱即用 +- **本项目已选型**:`go.mod` 中 `github.com/gin-gonic/gin v1.12.0` + +对比: + +| 方案 | 特点 | 适合 | +|------|------|------| +| `net/http` | 标准库,无依赖,可控 | 小服务、学习 | +| Gin | 高性能、API 友好 | RESTful API、中大型项目 | +| Echo/Fiber | 类似 Gin,Fiber 基于 fasthttp | 追求极致性能或特定风格 | + +## 3.2 安装与初始化 + +本项目已初始化,可直接 `make tidy`。从零新建: + +```bash +mkdir myapp && cd myapp +go mod init myapp +go get github.com/gin-gonic/gin +``` + +## 3.3 Hello World + +```go +package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() // 含 Logger + Recovery 中间件 + r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + r.Run(":8080") // 监听 0.0.0.0:8080 +} +``` + +运行 `go run main.go`,访问 `http://localhost:8080/ping`。 + +- `gin.H` 是 `map[string]any` 的别名,方便构造 JSON +- `c.JSON(code, obj)` 自动设置 `Content-Type: application/json` + +## 3.4 Gin 与 net/http 的关系 + +Gin 不是另起炉灶,而是对 `net/http` 的封装: + +```go +// Gin 实现了 http.Handler 接口,所以可直接给 http.Server +srv := &http.Server{ + Addr: ":8080", + Handler: r, // r 是 *gin.Engine,实现了 ServeHTTP +} +srv.ListenAndServe() +``` + +本项目 `cmd/server/main.go` 正是这种写法,以便支持优雅关闭(`srv.Shutdown`)。若用 `r.Run()` 则无法优雅关闭,仅适合 demo。 + +`gin.Context` 包装了 `http.Request` 与 `http.ResponseWriter`,提供 `Param`/`Query`/`Bind`/`JSON` 等快捷方法。 + +## 3.5 gin.Default() vs gin.New() + +```go +// Default 自带 Logger + Recovery +r := gin.Default() + +// New 空引擎,需手动注册(本项目采用,便于替换为 Zap) +r := gin.New() +r.Use(middleware.Recovery()) +r.Use(middleware.ZapLogger()) +``` + +本项目 `internal/router/router.go` 使用 `gin.New()` 并注册自定义 Zap 日志与 Recovery,生产更可控。 + +## 3.6 运行模式 + +```go +gin.SetMode(gin.DebugMode) // 开发:详细日志 +gin.SetMode(gin.ReleaseMode) // 生产:精简日志 +gin.SetMode(gin.TestMode) // 测试 +``` + +本项目由 `config.yaml` 的 `server.mode` 控制,在 `router.New` 中 `gin.SetMode(cfg.Server.Mode)`。 + +## 3.7 项目初始化对照 + +本项目入口 `cmd/server/main.go` 关键步骤: + +1. `config.Load()` 加载配置 +2. `logger.Init` 初始化 Zap +3. `router.New(cfg)` 创建 Gin 引擎 +4. `http.Server` + goroutine 启动 +5. `signal.Notify` 等待退出信号,`srv.Shutdown` 优雅关闭 + +这是生产级 Gin 服务的标准启动模板,建议熟记。 + +## 3.8 动手练习 + +1. 用 `gin.Default()` 写一个返回当前时间的 `/time` 接口。 +2. 改为 `gin.New()` + 自定义 Logger,观察差异。 +3. 将启动方式从 `r.Run` 改为 `http.Server` + 优雅关闭。 diff --git a/docs/04-gin-路由与分组.md b/docs/04-gin-路由与分组.md new file mode 100644 index 0000000..903e477 --- /dev/null +++ b/docs/04-gin-路由与分组.md @@ -0,0 +1,154 @@ +# 第 4 章 Gin 路由与分组 + +> 学完本章你将:熟练定义路由、处理路径/查询参数、组织分组、设计 RESTful API。 + +## 4.1 路由基础 + +Gin 支持所有 HTTP 方法: + +```go +r.GET("/ping", handler) +r.POST("/users", handler) +r.PUT("/users/:id", handler) +r.DELETE("/users/:id", handler) +r.PATCH("/users/:id", handler) +r.OPTIONS("/*any", handler) +r.Any("/any", handler) // 匹配任意方法 +``` + +本项目示例: + +```go +r.GET("/health", health.Check) // 健康检查 +v1 := r.Group("/api/v1") +v1.GET("/ping", pingHandler.Ping) // 分组路由 +``` + +## 4.2 路径参数 + +```go +// 定义 +r.GET("/users/:id", func(c *gin.Context) { + id := c.Param("id") // "/users/42" => "42" +}) + +// 多参数 +r.GET("/users/:id/books/:bookID", func(c *gin.Context) { + id := c.Param("id") + bookID := c.Param("bookID") +}) + +// 通配符(匹配剩余路径) +r.GET("/files/*filepath", func(c *gin.Context) { + fp := c.Param("filepath") // "/files/a/b.txt" => "/a/b.txt" +}) +``` + +## 4.3 查询参数与表单 + +```go +// GET /search?q=gin&page=2 +r.GET("/search", func(c *gin.Context) { + q := c.Query("q") // "gin" + page := c.DefaultQuery("page", "1") // 带默认值 + arr := c.QueryArray("tag") // ?tag=a&tag=b => [a b] + m := c.QueryMap("filter") // ?filter[name]=a => map[name:a] +}) + +// POST form +r.POST("/form", func(c *gin.Context) { + name := c.PostForm("name") + file, _ := c.FormFile("upload") + c.SaveUploadedFile(file, "./"+file.Filename) +}) +``` + +## 4.4 分组路由 + +分组用于版本、权限、模块划分: + +```go +v1 := r.Group("/api/v1") +{ + v1.GET("/ping", pingHandler.Ping) + v1.GET("/users", listUsers) + v1.POST("/users", createUser) +} + +// 嵌套分组 +admin := v1.Group("/admin", authMiddleware()) +{ + admin.GET("/stats", statsHandler) +} +``` + +本项目 `internal/router/router.go` 即采用分组:`r.GET("/health")` 独立于 `v1.Group("/api/v1")`,便于探活不走鉴权。 + +## 4.5 RESTful 设计示例 + +以用户资源为例: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/users | 列表 | +| POST | /api/v1/users | 创建 | +| GET | /api/v1/users/:id | 详情 | +| PUT | /api/v1/users/:id | 全量更新 | +| PATCH | /api/v1/users/:id | 部分更新 | +| DELETE | /api/v1/users/:id | 删除 | + +```go +users := v1.Group("/users") +{ + users.GET("", listUsers) + users.POST("", createUser) + users.GET("/:id", getUser) + users.PUT("/:id", updateUser) + users.DELETE("/:id", deleteUser) +} +``` + +## 4.6 路由注册实战(对照本项目) + +`internal/router/router.go` 完整模式: + +```go +func New(cfg *config.Config) *gin.Engine { + gin.SetMode(cfg.Server.Mode) + r := gin.New() + r.Use(middleware.Recovery()) + r.Use(middleware.ZapLogger()) + r.Use(middleware.Cors(cfg.Cors)) + + health := handler.NewHealthHandler() + r.GET("/health", health.Check) + + pingRepo := repo.NewPingRepo() + pingSvc := service.NewPingService(pingRepo) + pingHandler := handler.NewPingHandler(pingSvc) + + v1 := r.Group("/api/v1") + { v1.GET("/ping", pingHandler.Ping) } + + return r +} +``` + +依赖注入顺序:`repo -> service -> handler -> router`,各层职责清晰,新增模块时复制此模式即可。 + +## 4.7 重定向与 NoRoute + +```go +r.GET("/old", func(c *gin.Context) { + c.Redirect(301, "/new") +}) +r.NoRoute(func(c *gin.Context) { + c.JSON(404, gin.H{"code": 404, "msg": "not found"}) +}) +``` + +## 4.8 动手练习 + +1. 新增 `GET /api/v1/users/:id`,返回 `{"id": "xxx"}`。 +2. 实现 `GET /search` 支持 `q` 与 `page` 查询参数,`page` 默认 1。 +3. 将 `/health` 与 `/api/v1/*` 分为两组,给后者单独加一个日志中间件。 diff --git a/docs/05-gin-中间件.md b/docs/05-gin-中间件.md new file mode 100644 index 0000000..44ba644 --- /dev/null +++ b/docs/05-gin-中间件.md @@ -0,0 +1,166 @@ +# 第 5 章 Gin 中间件 + +> 学完本章你将:理解中间件洋葱模型、编写自定义中间件、掌握 Recovery/Logger/CORS/鉴权等常用中间件。 + +## 5.1 中间件原理 + +中间件是 `func(*gin.Context)`,通过 `c.Next()` 控制调用链,形成洋葱模型: + +``` +请求 -> MW1 -> MW2 -> Handler -> MW2 -> MW1 -> 响应 +``` + +```go +func MW1() gin.HandlerFunc { + return func(c *gin.Context) { + fmt.Println("MW1 before") + c.Next() // 调用后续 + fmt.Println("MW1 after") + } +} +r.Use(MW1(), MW2()) +``` + +若不调用 `c.Next()` 或调用 `c.Abort()`,则中断后续。`c.AbortWithStatusJSON` 直接返回响应。 + +## 5.2 注册级别 + +```go +// 全局 +r.Use(middleware.ZapLogger()) + +// 分组 +v1 := r.Group("/api/v1", authMiddleware()) +authed := r.Group("/admin") +authed.Use(authMiddleware()) + +// 单路由 +r.GET("/secret", authMiddleware(), secretHandler) +``` + +本项目 `router.New` 中三件套全局注册:`Recovery`、`ZapLogger`、`Cors`。 + +## 5.3 Recovery 恢复 panic + +`internal/middleware/recovery.go` 源码精读: + +```go +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + logger.Log.Error("panic recovered", ...) + c.AbortWithStatusJSON(500, gin.H{"code":500,"msg":"internal server error"}) + } + }() + c.Next() + } +} +``` + +- 防止单请求 panic 导致进程崩溃 +- 记录错误日志并返回 500 +- 业务代码中不要滥用 panic + +## 5.4 ZapLogger 请求日志 + +`internal/middleware/logger.go`: + +```go +func ZapLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + c.Next() + latency := time.Since(start) + logger.Log.Info("request", + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.Int("status", c.Writer.Status()), + zap.Duration("latency", latency), + ) + } +} +``` + +记录方法、路径、状态码、延迟、IP、User-Agent。生产可在此追加 traceID。 + +## 5.5 CORS 跨域 + +`internal/middleware/cors.go` 核心逻辑: + +- 根据 `config.CorsConfig.AllowOrigins` 判断是否允许 Origin +- 设置 `Access-Control-Allow-Origin` / `Methods` / `Headers` +- `OPTIONS` 预检请求直接 `204` 返回 + +配置见 `config/config.yaml`: + +```yaml +cors: + allow_origins: ["*"] + allow_methods: ["GET","POST","PUT","DELETE","OPTIONS"] +``` + +生产建议收紧为具体域名,避免 `*` + `Allow-Credentials: true` 的组合(浏览器会拦截)。 + +## 5.6 自定义鉴权中间件示例 + +```go +func Auth() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.AbortWithStatusJSON(401, gin.H{"code": 401, "msg": "unauthorized"}) + return + } + // 校验 token,解析用户 + c.Set("userID", 123) // 传递给后续 handler + c.Next() + } +} + +// handler 中获取 +func Me(c *gin.Context) { + uid, _ := c.Get("userID") + c.JSON(200, gin.H{"userID": uid}) +} +``` + +本项目已引入 `github.com/golang-jwt/jwt/v5` 与 `casbin`,可在鉴权 middleware 中集成 JWT 校验 + Casbin 鉴权。 + +## 5.7 限流/超时/请求ID(扩展) + +```go +// 请求ID +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.GetHeader("X-Request-ID") + if id == "" { id = uuid.NewString() } + c.Set("requestID", id) + c.Header("X-Request-ID", id) + c.Next() + } +} + +// 超时 +func Timeout(d time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), d) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} +``` + +## 5.8 常见坑 + +- 中间件顺序重要:Recovery 应最先注册,保证捕获所有后续 panic +- `c.Abort()` 后仍会执行已进入的中间件的 `c.Next()` 之后代码,需注意逻辑 +- 不要在中间件中做过重同步操作,会阻塞所有请求 + +## 5.9 动手练习 + +1. 编写 `RequestID` 中间件并在日志中打印。 +2. 编写 `Auth` 中间件,校验 `Authorization: Bearer `,失败返回 401。 +3. 调整本项目 `router.New` 中间件顺序,观察 panic 时日志差异。 diff --git a/docs/06-gin-请求绑定与响应.md b/docs/06-gin-请求绑定与响应.md new file mode 100644 index 0000000..5528914 --- /dev/null +++ b/docs/06-gin-请求绑定与响应.md @@ -0,0 +1,164 @@ +# 第 6 章 Gin 请求绑定与响应 + +> 学完本章你将:优雅地绑定与校验请求参数,统一响应格式,处理错误。 + +## 6.1 参数绑定 + +Gin 提供 `ShouldBind` 系列,自动根据 Content-Type 选择绑定器: + +```go +// JSON: POST {"name":"alice","age":18} +type CreateUserReq struct { + Name string `json:"name" binding:"required"` + Age int `json:"age" binding:"gte=0,lte=120"` +} +var req CreateUserReq +if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return +} + +// Query: GET /users?page=1&size=10 +type ListReq struct { + Page int `form:"page" binding:"gte=1"` + Size int `form:"size" binding:"gte=1,lte=100"` +} +var q ListReq +c.ShouldBindQuery(&q) + +// Path + Query + Form 混合:ShouldBind 自动识别 +c.ShouldBind(&req) + +// Header +token := c.GetHeader("Authorization") +// 默认值 +page := c.DefaultQuery("page", "1") +``` + +校验标签基于 `go-playground/validator`,本项目已间接依赖 `validator/v10`。 + +## 6.2 常用校验标签 + +| 标签 | 说明 | +|------|------| +| `required` | 必填 | +| `gte=0` `lte=120` | 数值范围 | +| `oneof=debug release` | 枚举 | +| `email` `url` | 格式 | +| `min=3` `max=20` | 字符串长度 | +| `len=6` | 定长 | + +```go +type LoginReq struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` +} +``` + +自定义校验: + +```go +if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + v.RegisterValidation("port", func(fl validator.FieldLevel) bool { + p := fl.Field().Int() + return p > 0 && p < 65535 + }) +} +``` + +## 6.3 统一响应 + +本项目 `pkg/response/response.go` 已封装: + +```go +type Result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +func Success(c *gin.Context, data interface{}) { + c.JSON(200, Result{Code: 0, Msg: "success", Data: data}) +} +func BadRequest(c *gin.Context, msg string) { + c.JSON(400, Result{Code: 400, Msg: msg}) +} +``` + +使用: + +```go +// handler 中 +func (h *PingHandler) Ping(c *gin.Context) { + msg := h.svc.Ping() + response.Success(c, gin.H{"message": msg}) +} +// => {"code":0,"msg":"success","data":{"message":"pong"}} + +func CreateUser(c *gin.Context) { + var req CreateUserReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, gin.H{"id": 1}) +} +``` + +约定:`Code 0` 为成功,非 0 为业务错误码;HTTP 状态码反映传输层语义(400 参数错误、401 未认证、500 服务错误)。 + +## 6.4 错误处理策略 + +- 参数错误:`400 BadRequest`,返回校验信息 +- 业务错误:按需定义错误码,如 `10001 用户不存在` +- 系统错误:`500 InternalError`,日志记录详情但不暴露给客户端 + +```go +func Fail(c *gin.Context, httpCode, code int, msg string) { + c.JSON(httpCode, Result{Code: code, Msg: msg}) +} +``` + +建议结合 `errors.Is/As` 判断错误类型,在 service 层返回 `error`,handler 层映射为响应。 + +## 6.5 文件上传与下载 + +```go +// 上传 +r.POST("/upload", func(c *gin.Context) { + file, _ := c.FormFile("file") + c.SaveUploadedFile(file, "./uploads/"+file.Filename) + response.Success(c, gin.H{"filename": file.Filename}) +}) + +// 下载/返回文件 +r.GET("/download/:name", func(c *gin.Context) { + c.File("./uploads/" + c.Param("name")) +}) +``` + +## 6.6 完整示例 + +```go +type CreateBookReq struct { + Title string `json:"title" binding:"required"` + Author string `json:"author" binding:"required"` + Price int `json:"price" binding:"gte=0"` +} + +func CreateBook(c *gin.Context) { + var req CreateBookReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + // 调用 service... + response.Success(c, gin.H{"title": req.Title}) +} +``` + +## 6.7 动手练习 + +1. 新增 `POST /api/v1/users`,用 `ShouldBindJSON` + `required/email` 校验。 +2. 实现 `GET /api/v1/users?page=&size=` 分页查询,用 `ShouldBindQuery`。 +3. 为 `BadRequest` 补充单元测试,验证错误信息是否友好。 diff --git a/docs/07-实战-StreamBox项目解析.md b/docs/07-实战-StreamBox项目解析.md new file mode 100644 index 0000000..1d63ae8 --- /dev/null +++ b/docs/07-实战-StreamBox项目解析.md @@ -0,0 +1,210 @@ +# 第 7 章 实战:StreamBox 项目解析 + +> 学完本章你将:彻底看懂本项目分层架构,能独立新增一个 CRUD 模块,并理解配置/日志/优雅关闭等生产要素。 + +## 7.1 项目结构总览 + +``` +StreamBox/ +├── cmd/server/main.go # 入口:配置→日志→路由→启动→优雅关闭 +├── config/ +│ ├── config.go # Viper 加载逻辑 +│ └── config.yaml # 默认配置 +├── internal/ +│ ├── handler/ # HTTP 层:参数绑定、调用 service、返回 response +│ ├── service/ # 业务层:核心逻辑,可被多 handler 复用 +│ ├── repo/ # 数据层:DB/缓存/外部接口(当前为内存 demo) +│ ├── model/ # 数据模型 +│ ├── middleware/ # Recovery/ZapLogger/Cors +│ └── router/router.go # 路由组装与依赖注入 +├── pkg/ +│ ├── logger/ # Zap 封装 +│ └── response/ # 统一 Result +├── api/ # Swagger/API 文档(待扩展) +└── Makefile # run/build/tidy/fmt +``` + +分层原则:`handler → service → repo` 单向依赖,`pkg` 为可复用基础库,`internal` 禁止外部 import。 + +## 7.2 入口:cmd/server/main.go 逐行解析 + +```go +cfg, err := config.Load() // 1. 加载配置 +logger.Init(cfg.Log.Level, cfg.Log.Encoding) // 2. 初始化日志 +r := router.New(cfg) // 3. 构建 Gin 引擎 +srv := &http.Server{Addr: addr, Handler: r} // 4. 用标准 http.Server 包装 +go srv.ListenAndServe() // 5. goroutine 启动 +<-quit // 6. 阻塞等待 SIGINT/SIGTERM +srv.Shutdown(ctx) // 7. 10 秒优雅关闭 +``` + +为什么不用 `r.Run()`:`r.Run()` 内部直接 `ListenAndServe` 且无法捕获信号做 `Shutdown`,会丢失正在处理的请求。生产务必用 `http.Server + Shutdown`。 + +## 7.3 配置:config/config.go + viper + +`config.yaml` 示例: + +```yaml +server: + port: 8080 + mode: debug + read_timeout: 10s + write_timeout: 10s +log: + level: debug + encoding: console +cors: + allow_origins: ["*"] +``` + +`config.Load()` 逻辑: + +1. `viper.SetConfigFile("config/config.yaml")` + `ReadInConfig` +2. `viper.AutomaticEnv()` 环境变量覆盖(`SERVER_PORT=9090` 可覆盖 `server.port`) +3. `Unmarshal` 到 `Config` 结构体 + +新增配置:结构体加字段 → yaml 加默认值 → 代码中 `cfg.NewField` 使用。 + +## 7.4 日志:pkg/logger + +```go +// 初始化 +logger.Init(cfg.Log.Level, cfg.Log.Encoding) +// 使用 +logger.Log.Info("request", zap.String("path", path)) +logger.Sugar.Infow("server starting", "addr", addr) +``` + +- `console` 适合开发,`json` 适合生产(便于 ELK 收集) +- `level` 动态控制:debug/info/warn/error +- `defer logger.Sync()` 刷盘,避免日志丢失 + +## 7.5 路由与依赖注入:internal/router/router.go + +```go +func New(cfg *config.Config) *gin.Engine { + gin.SetMode(cfg.Server.Mode) + r := gin.New() + r.Use(middleware.Recovery()) + r.Use(middleware.ZapLogger()) + r.Use(middleware.Cors(cfg.Cors)) + + health := handler.NewHealthHandler() + r.GET("/health", health.Check) + + pingRepo := repo.NewPingRepo() + pingSvc := service.NewPingService(pingRepo) + pingHandler := handler.NewPingHandler(pingSvc) + v1 := r.Group("/api/v1") + { v1.GET("/ping", pingHandler.Ping) } + return r +} +``` + +新增模块时复制这三行:`repo → service → handler`,然后挂到分组。 + +## 7.6 Handler/Service/Repo 职责 + +- **handler**(`internal/handler/ping.go`):只做 HTTP 相关——取参、校验、调 service、组响应。不写业务逻辑。 +- **service**(`internal/service/ping.go`):业务逻辑,可组合多个 repo,返回 `error` 由 handler 转为响应。 +- **repo**(`internal/repo/ping.go`):数据访问,当前为内存实现,未来可替换为 GORM/SQLite。 + +示例 `PingService`: + +```go +type PingService struct { repo *repo.PingRepo } +func (s *PingService) Ping() string { return s.repo.Ping() } +``` + +## 7.7 完整 CRUD 实战(以 Book 为例) + +按分层新增一个资源,需 4 个文件 + 1 处路由: + +**1. model** `internal/model/book.go` + +```go +package model + +type Book struct { + ID uint `json:"id" gorm:"primaryKey"` + Title string `json:"title" binding:"required"` + Author string `json:"author" binding:"required"` +} +``` + +**2. repo** `internal/repo/book.go` + +```go +package repo + +import "streambox/internal/model" + +type BookRepo struct { db map[uint]*model.Book; nextID uint } +func NewBookRepo() *BookRepo { return &BookRepo{db: make(map[uint]*model.Book), nextID: 1} } +func (r *BookRepo) Create(b *model.Book) *model.Book { b.ID = r.nextID; r.nextID++; r.db[b.ID] = b; return b } +func (r *BookRepo) Get(id uint) (*model.Book, bool) { b, ok := r.db[id]; return b, ok } +func (r *BookRepo) List() []*model.Book { // ... + return nil +} +``` + +**3. service** `internal/service/book.go` + +```go +package service + +import "streambox/internal/model" + +type BookService struct { repo *repo.BookRepo } +func NewBookService(repo *repo.BookRepo) *BookService { return &BookService{repo: repo} } +func (s *BookService) Create(b *model.Book) *model.Book { return s.repo.Create(b) } +``` + +**4. handler** `internal/handler/book.go` + +```go +type BookHandler struct { svc *service.BookService } +func NewBookHandler(svc *service.BookService) *BookHandler { return &BookHandler{svc: svc} } + +func (h *BookHandler) Create(c *gin.Context) { + var req model.Book + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()); return + } + book := h.svc.Create(&req) + response.Success(c, book) +} +func (h *BookHandler) Get(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if b, ok := h.svc.Get(uint(id)); ok { + response.Success(c, b) + } else { + response.Fail(c, 404, 404, "book not found") + } +} +``` + +**5. router** `internal/router/router.go` 追加 + +```go +bookRepo := repo.NewBookRepo() +bookSvc := service.NewBookService(bookRepo) +bookHandler := handler.NewBookHandler(bookSvc) +v1.POST("/books", bookHandler.Create) +v1.GET("/books/:id", bookHandler.Get) +v1.GET("/books", bookHandler.List) +``` + +重启 `make run`,`curl -X POST localhost:8080/api/v1/books -H 'Content-Type: application/json' -d '{"title":"Go","author":"A"}'` 验证。 + +> 替换为真实数据库时,只需将 `BookRepo` 内部改为 `*gorm.DB`,service/handler 无需改动,这就是分层的价值。 + +## 7.8 统一响应与错误 + +所有 handler 均通过 `pkg/response` 返回,避免 `c.JSON` 散落各处,便于前端统一处理。 + +## 7.9 动手练习 + +1. 按上述步骤完整实现 Book 的 List/Update/Delete。 +2. 将 BookRepo 替换为 GORM + SQLite(项目已依赖 `glebarez/sqlite`)。 +3. 为 Book 补充 `go test` 单测。 diff --git a/docs/08-部署与进阶.md b/docs/08-部署与进阶.md new file mode 100644 index 0000000..e844406 --- /dev/null +++ b/docs/08-部署与进阶.md @@ -0,0 +1,120 @@ +# 第 8 章 部署与进阶 + +> 学完本章你将:编译与部署 Gin 服务,用 Docker 容器化,掌握生产配置与后续学习路线。 + +## 8.1 编译与运行 + +```bash +make build # go build -o bin/streambox ./cmd/server +./bin/streambox # 直接运行二进制 + +# 交叉编译 +GOOS=linux GOARCH=amd64 go build -o bin/streambox-linux ./cmd/server + +# 运行时配置 +SERVER_PORT=9090 LOG_LEVEL=info ./bin/streambox +# 或修改 config/config.yaml +``` + +二进制无依赖,拷贝到服务器即可运行。 + +## 8.2 优雅关闭回顾 + +`cmd/server/main.go` 已实现: + +```go +quit := make(chan os.Signal, 1) +signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) +<-quit +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +srv.Shutdown(ctx) +``` + +`Shutdown` 会等待正在处理的请求完成(最多 10 秒),再退出进程,避免 k8s 滚动更新时丢请求。需配合 `ReadTimeout`/`WriteTimeout`(见 `config.yaml`)。 + +## 8.3 生产配置 + +| 项 | 建议 | +|----|------| +| `server.mode` | `release`(关闭 Gin debug 日志) | +| `log.level` | `info` 或 `warn` | +| `log.encoding` | `json`(便于日志收集) | +| `cors.allow_origins` | 具体域名,不用 `*` | +| 端口 | 环境变量 `SERVER_PORT` 覆盖,避免改文件 | + +敏感信息(DB 密码、JWT 密钥)务必走环境变量或 Secret,不提交到 Git。参考 `.env.example`。 + +## 8.4 Dockerfile + +```dockerfile +# 构建阶段 +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /streambox ./cmd/server + +# 运行阶段 +FROM alpine:3.20 +WORKDIR /app +COPY --from=builder /streambox /app/streambox +COPY config/config.yaml /app/config/config.yaml +EXPOSE 8080 +CMD ["/app/streambox"] +``` + +```bash +docker build -t streambox:latest . +docker run -p 8080:8080 -e SERVER_PORT=8080 streambox:latest +curl http://localhost:8080/health +``` + +> 若使用 `glebarez/sqlite`(纯 Go 驱动),`CGO_ENABLED=0` 可用;若用 `mattn/go-sqlite3` 需 `CGO_ENABLED=1`。 + +## 8.5 反向代理与多实例 + +生产常在 Gin 前加 Nginx/Caddy 做 TLS、静态资源、限流: + +```nginx +upstream streambox { server 127.0.0.1:8080; } +server { + listen 80; + location / { proxy_pass http://streambox; } +} +``` + +多实例用 k8s Deployment + Service,或 systemd 多进程 + LB。 + +## 8.6 性能与可观测性 + +- **pprof**:`import _ "net/http/pprof"` + `r.GET("/debug/pprof/*any", gin.WrapH(http.DefaultServeMux))`(仅内网暴露) +- **压测**:`go test -bench`、`wrk`、`hey` +- **追踪**:日志中加入 `requestID`(见第 5 章中间件) +- **指标**:可接入 Prometheus(`ginprom` 中间件) + +## 8.7 下一步学习路线 + +1. **数据库**:GORM + SQLite/PostgreSQL,事务与迁移 +2. **鉴权**:JWT(`golang-jwt/jwt`)+ Casbin(本项目已引入) +3. **文档**:Swagger(`swaggo/gin-swagger` 本项目已引入,`swag init` 生成) +4. **验证**:深入 `validator` 自定义规则 +5. **测试**:`httptest` + `testify` 做 handler 集成测试 +6. **进阶**:依赖注入(Wire/Fx)、配置中心、消息队列 + +## 8.8 常用命令速查 + +```bash +make run # 开发运行 +make build # 编译 +make tidy # 整理依赖 +make fmt # 格式化 +go test ./... # 测试 +go vet ./... # 静态检查 +curl http://localhost:8080/health +``` + +--- + +至此,Go + Gin 从入门到部署已全部覆盖。建议从第 7 章的 Book CRUD 开始,亲手扩展一个完整模块,遇到问题回查对应章节。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..aaa44a4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# Go + Gin 开发教程 + +> 基于本项目 StreamBox(Go 1.26.2 + Gin v1.12.0)编写的系统化教程,从零搭建到生产部署。 + +## 适用人群 + +- 有任意一门编程语言基础,想系统学习 Go +- 想用 Gin 快速开发 RESTful API 的后端开发者 +- 想看懂/维护本项目 `StreamBox` 代码的同学 + +## 目录 + +| 章节 | 主题 | 核心内容 | +|------|------|----------| +| [第 1 章](01-go-环境与基础语法.md) | Go 环境与基础语法 | 安装、go mod、变量/类型/流程/函数/结构体/接口/错误处理 | +| [第 2 章](02-go-进阶-并发与工程化.md) | Go 进阶:并发与工程化 | goroutine/channel/context/sync、测试、常用工具链 | +| [第 3 章](03-gin-入门.md) | Gin 入门 | 为什么选 Gin、Hello World、与 net/http 对比、项目初始化 | +| [第 4 章](04-gin-路由与分组.md) | Gin 路由与分组 | 路由定义、路径参数、查询参数、分组、RESTful 设计 | +| [第 5 章](05-gin-中间件.md) | Gin 中间件 | 原理、自定义、全局/分组/路由级、Recovery/Logger/CORS、鉴权 | +| [第 6 章](06-gin-请求绑定与响应.md) | 请求绑定与响应 | 参数绑定、校验、统一响应、错误处理 | +| [第 7 章](07-实战-StreamBox项目解析.md) | 实战:StreamBox 项目解析 | 分层架构、配置、日志、优雅关闭、完整 CRUD 示例 | +| [第 8 章](08-部署与进阶.md) | 部署与进阶 | 编译、Docker、生产配置、性能与下一阶段学习路线 | + +## 如何阅读 + +1. 按顺序阅读,第 1-2 章打 Go 基础,第 3-6 章掌握 Gin,第 7 章对照本项目源码,第 8 章上线。 +2. 每章末尾有“动手练习”,建议敲一遍。 +3. 本教程所有代码均可在本仓库直接运行:`make run` 启动,`curl http://localhost:8080/health` 验证。 + +## 快速开始 + +```bash +# 克隆后直接运行 +make tidy +make run + +# 验证 +curl http://localhost:8080/health +# => {"code":0,"msg":"success","data":{"status":"ok"}} + +curl http://localhost:8080/api/v1/ping +# => {"code":0,"msg":"success","data":{"message":"pong"}} +``` + +--- + +> 约定:教程中 `streambox` 为 module 名(见 `go.mod`),示例代码可直接复制到本项目中运行。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0fc39dc --- /dev/null +++ b/go.mod @@ -0,0 +1,79 @@ +module streambox + +go 1.26.2 + +require ( + github.com/casbin/casbin/v2 v2.135.0 + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.6.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/viper v1.21.0 + github.com/swaggo/gin-swagger v1.6.1 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.55.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/casbin/govaluate v1.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.20.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // 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.30.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // 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.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/swag v1.8.12 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gorm.io/gorm v1.24.2 // indirect + modernc.org/libc v1.21.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.4.0 // indirect + modernc.org/sqlite v1.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3c6786 --- /dev/null +++ b/go.sum @@ -0,0 +1,273 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8= +github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak= +github.com/glebarez/sqlite v1.6.0 h1:ZpvDLv4zBi2cuuQPitRiVz/5Uh6sXa5d8eBu0xNTpAo= +github.com/glebarez/sqlite v1.6.0/go.mod h1:6D6zPU/HTrFlYmVDKqBJlmQvma90P6r7sRRdkUUZOYk= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +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.6.1/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w= +github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its= +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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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= +gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0= +gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= +modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0= +modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= +modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= +modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI= +modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY= +modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..284d3bf --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,15 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "streambox/pkg/response" +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { return &HealthHandler{} } + +func (h *HealthHandler) Check(c *gin.Context) { + response.Success(c, gin.H{"status": "ok"}) +} diff --git a/internal/handler/ping.go b/internal/handler/ping.go new file mode 100644 index 0000000..92b779d --- /dev/null +++ b/internal/handler/ping.go @@ -0,0 +1,21 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "streambox/internal/service" + "streambox/pkg/response" +) + +type PingHandler struct { + svc *service.PingService +} + +func NewPingHandler(svc *service.PingService) *PingHandler { + return &PingHandler{svc: svc} +} + +func (h *PingHandler) Ping(c *gin.Context) { + msg := h.svc.Ping() + response.Success(c, gin.H{"message": msg}) +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..1a36567 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "streambox/config" +) + +func Cors(cfg config.CorsConfig) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + allowed := false + for _, o := range cfg.AllowOrigins { + if o == "*" || o == origin { + allowed = true + break + } + } + if allowed { + if len(cfg.AllowOrigins) == 1 && cfg.AllowOrigins[0] == "*" { + c.Header("Access-Control-Allow-Origin", "*") + } else if origin != "" { + c.Header("Access-Control-Allow-Origin", origin) + } + c.Header("Vary", "Origin") + } + c.Header("Access-Control-Allow-Credentials", "true") + if len(cfg.AllowMethods) > 0 { + c.Header("Access-Control-Allow-Methods", join(cfg.AllowMethods)) + } + if len(cfg.AllowHeaders) > 0 { + c.Header("Access-Control-Allow-Headers", join(cfg.AllowHeaders)) + } + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + } +} + +func join(a []string) string { + s := "" + for i, v := range a { + if i > 0 { + s += ", " + } + s += v + } + return s +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..8ba7072 --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "streambox/pkg/logger" +) + +func ZapLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + c.Next() + latency := time.Since(start) + if logger.Log != nil { + fields := []zap.Field{ + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", query), + zap.Int("status", c.Writer.Status()), + zap.Duration("latency", latency), + zap.String("ip", c.ClientIP()), + zap.String("user-agent", c.Request.UserAgent()), + } + if len(c.Errors) > 0 { + fields = append(fields, zap.String("errors", c.Errors.String())) + } + logger.Log.Info("request", fields...) + } + } +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..e408521 --- /dev/null +++ b/internal/middleware/recovery.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "streambox/pkg/logger" +) + +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + if logger.Log != nil { + logger.Log.Error("panic recovered", zap.Any("panic", r), zap.String("path", c.Request.URL.Path)) + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "code": 500, + "msg": "internal server error", + }) + } + }() + c.Next() + } +} diff --git a/internal/model/common.go b/internal/model/common.go new file mode 100644 index 0000000..eb41004 --- /dev/null +++ b/internal/model/common.go @@ -0,0 +1,25 @@ +package model + +import "time" + +type Pagination struct { + Page int `form:"page" binding:"omitempty,min=1"` + PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` +} + +func (p *Pagination) Normalize() { + if p.Page <= 0 { + p.Page = 1 + } + if p.PageSize <= 0 { + p.PageSize = 10 + } +} + +func (p Pagination) Offset() int { return (p.Page - 1) * p.PageSize } + +type BaseModel struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/repo/demo.go b/internal/repo/demo.go new file mode 100644 index 0000000..2170ec9 --- /dev/null +++ b/internal/repo/demo.go @@ -0,0 +1,22 @@ +package repo + +import ( + "context" + + "streambox/internal/model" +) + +// DemoRepo 演示带接口抽象的仓储写法,供后续业务直接扩展 +type DemoRepo interface { + List(ctx context.Context, p model.Pagination) ([]string, error) +} + +type demoRepo struct{} + +func NewDemoRepo() DemoRepo { return &demoRepo{} } + +func (r *demoRepo) List(_ context.Context, p model.Pagination) ([]string, error) { + p.Normalize() + // 占位实现,后续接入 GORM/sqlx 等 + return []string{}, nil +} diff --git a/internal/repo/ping.go b/internal/repo/ping.go new file mode 100644 index 0000000..8e86a44 --- /dev/null +++ b/internal/repo/ping.go @@ -0,0 +1,8 @@ +package repo + +// PingRepo 示例仓储层,后续可替换为真实 DB/缓存实现 +type PingRepo struct{} + +func NewPingRepo() *PingRepo { return &PingRepo{} } + +func (r *PingRepo) GetMessage() string { return "pong" } diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..b64ac90 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,35 @@ +package router + +import ( + "github.com/gin-gonic/gin" + + "streambox/config" + "streambox/internal/handler" + "streambox/internal/middleware" + "streambox/internal/repo" + "streambox/internal/service" +) + +func New(cfg *config.Config) *gin.Engine { + gin.SetMode(cfg.Server.Mode) + r := gin.New() + r.Use(middleware.Recovery()) + r.Use(middleware.ZapLogger()) + r.Use(middleware.Cors(cfg.Cors)) + + // 健康检查(不带前缀,方便探活) + health := handler.NewHealthHandler() + r.GET("/health", health.Check) + + // 依赖注入:repo -> service -> handler + pingRepo := repo.NewPingRepo() + pingSvc := service.NewPingService(pingRepo) + pingHandler := handler.NewPingHandler(pingSvc) + + v1 := r.Group("/api/v1") + { + v1.GET("/ping", pingHandler.Ping) + } + + return r +} diff --git a/internal/service/ping.go b/internal/service/ping.go new file mode 100644 index 0000000..3c19d4f --- /dev/null +++ b/internal/service/ping.go @@ -0,0 +1,11 @@ +package service + +import "streambox/internal/repo" + +type PingService struct { + repo *repo.PingRepo +} + +func NewPingService(r *repo.PingRepo) *PingService { return &PingService{repo: r} } + +func (s *PingService) Ping() string { return s.repo.GetMessage() } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..19f729d --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,31 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var Log *zap.Logger +var Sugar *zap.SugaredLogger + +func Init(level, encoding string) error { + cfg := zap.NewProductionConfig() + if encoding == "console" { + cfg.Encoding = "console" + cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + } else { + cfg.Encoding = "json" + } + if lvl, err := zapcore.ParseLevel(level); err == nil { + cfg.Level = zap.NewAtomicLevelAt(lvl) + } + l, err := cfg.Build(zap.AddCaller(), zap.AddCallerSkip(1)) + if err != nil { + return err + } + Log = l + Sugar = l.Sugar() + return nil +} + +func Sync() { _ = Log.Sync() } diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..cc04171 --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,36 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Result{Code: 0, Msg: "success", Data: data}) +} + +func SuccessWithMsg(c *gin.Context, msg string, data interface{}) { + c.JSON(http.StatusOK, Result{Code: 0, Msg: msg, Data: data}) +} + +func Fail(c *gin.Context, httpCode, code int, msg string) { + c.JSON(httpCode, Result{Code: code, Msg: msg}) +} + +func BadRequest(c *gin.Context, msg string) { + Fail(c, http.StatusBadRequest, 400, msg) +} + +func InternalError(c *gin.Context, msg string) { + if msg == "" { + msg = "internal server error" + } + Fail(c, http.StatusInternalServerError, 500, msg) +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..e846d7e --- /dev/null +++ b/tools.go @@ -0,0 +1,11 @@ +//go:build tools + +package tools + +import ( + _ "github.com/casbin/casbin/v2" + _ "github.com/glebarez/sqlite" + _ "github.com/golang-jwt/jwt/v5" + _ "github.com/swaggo/gin-swagger" + _ "golang.org/x/crypto/bcrypt" +)