commit 68be426e6a0a6b4e3c3b321971d56a99bf06df54 Author: well <347471159@qq.com> Date: Fri Mar 27 10:42:46 2026 +0800 first commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/gin_test.iml b/.idea/gin_test.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/gin_test.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5aadd30 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3409730 --- /dev/null +++ b/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" +) + +// Config 是 YAML 配置结构(只用 YAML,不依赖环境变量)。 +type Config struct { + Server ServerConfig `yaml:"server"` + JWT JWTConfig `yaml:"jwt"` + DB DBConfig `yaml:"db"` +} + +type ServerConfig struct { + Addr string `yaml:"addr"` + GinMode string `yaml:"gin_mode"` + GracefulTimeoutSeconds int `yaml:"graceful_timeout_seconds"` + ReadHeaderTimeoutSeconds int `yaml:"read_header_timeout_seconds"` +} + +type JWTConfig struct { + Secret string `yaml:"secret"` + ExpirySeconds int `yaml:"expiry_seconds"` +} + +type DBConfig struct { + Driver string `yaml:"driver"` + DSN string `yaml:"dsn"` + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + ConnMaxLifetimeSeconds int `yaml:"conn_max_lifetime_seconds"` +} + +// Load 读取 config/config.yaml。 +func Load() Config { + path := filepath.Join("config", "config.yaml") + raw, err := os.ReadFile(path) + if err != nil { + panic(err) + } + + var cfg Config + if err := yaml.Unmarshal(raw, &cfg); err != nil { + panic(err) + } + + // defaults + if cfg.Server.Addr == "" { + cfg.Server.Addr = ":8080" + } + if cfg.Server.GinMode == "" { + cfg.Server.GinMode = gin.ReleaseMode + } + + ginModeNorm := strings.ToLower(strings.TrimSpace(cfg.Server.GinMode)) + switch ginModeNorm { + case "debug": + cfg.Server.GinMode = gin.DebugMode + case "test": + cfg.Server.GinMode = gin.TestMode + case "release": + cfg.Server.GinMode = gin.ReleaseMode + default: + // 若未知,按 release 兜底。 + cfg.Server.GinMode = gin.ReleaseMode + } + + if cfg.Server.GracefulTimeoutSeconds <= 0 { + cfg.Server.GracefulTimeoutSeconds = 5 + } + if cfg.Server.ReadHeaderTimeoutSeconds <= 0 { + cfg.Server.ReadHeaderTimeoutSeconds = 5 + } + + if cfg.JWT.Secret == "" { + cfg.JWT.Secret = "dev-secret-change-me" + } + if cfg.JWT.ExpirySeconds <= 0 { + cfg.JWT.ExpirySeconds = 3600 + } + + if cfg.DB.Driver == "" { + cfg.DB.Driver = "sqlite" + } + if cfg.DB.DSN == "" { + return cfg // 交给 model 层校验并给出更明确错误 + } + + // 连接池 defaults(可选) + if cfg.DB.MaxOpenConns <= 0 { + cfg.DB.MaxOpenConns = 10 + } + if cfg.DB.MaxIdleConns <= 0 { + cfg.DB.MaxIdleConns = 5 + } + if cfg.DB.ConnMaxLifetimeSeconds <= 0 { + cfg.DB.ConnMaxLifetimeSeconds = 3600 + } + + return cfg +} + +// ServerDuration 便于下层直接使用 time.Duration。 +func (c Config) ServerDuration() (gracefulTimeout time.Duration, readHeaderTimeout time.Duration) { + if c.Server.GracefulTimeoutSeconds <= 0 { + return 5 * time.Second, 5 * time.Second + } + if c.Server.ReadHeaderTimeoutSeconds <= 0 { + return time.Duration(c.Server.GracefulTimeoutSeconds) * time.Second, 5 * time.Second + } + return time.Duration(c.Server.GracefulTimeoutSeconds) * time.Second, time.Duration(c.Server.ReadHeaderTimeoutSeconds) * time.Second +} + +// Validate 校验配置的必填项(用于启动时报错)。 +func (c Config) Validate() error { + if c.DB.DSN == "" { + return errors.New("db.dsn is empty in config/config.yaml") + } + return nil +} + diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..474dacb --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,18 @@ +server: + addr: ":8080" + gin_mode: "release" # debug/test/release + graceful_timeout_seconds: 5 + read_header_timeout_seconds: 5 + +jwt: + secret: "dev-secret-change-me" + expiry_seconds: 3600 + +db: + driver: "sqlite" + # modernc sqlite (纯 Go) + dsn: "file:gin_test.db?_foreign_keys=1" + max_open_conns: 10 + max_idle_conns: 5 + conn_max_lifetime_seconds: 3600 + diff --git a/di/app.go b/di/app.go new file mode 100644 index 0000000..bcbaf18 --- /dev/null +++ b/di/app.go @@ -0,0 +1,15 @@ +package di + +import ( + "database/sql" + "net/http" + "time" +) + +// App 是 wire 初始化后的运行时依赖集合。 +type App struct { + DB *sql.DB + Server *http.Server + GracefulTimeout time.Duration +} + diff --git a/di/providers.go b/di/providers.go new file mode 100644 index 0000000..8fdd98e --- /dev/null +++ b/di/providers.go @@ -0,0 +1,104 @@ +package di + +import ( + "context" + "database/sql" + "net/http" + "time" + + nice "github.com/ekyoung/gin-nice-recovery" + "github.com/gin-gonic/gin" + + "gin_test/config" + "gin_test/event" + "gin_test/model" + authmod "gin_test/modules/auth" + usermod "gin_test/modules/user" + "gin_test/pkg/jwt" + "gin_test/routes" +) + +// migrated 表示已完成 DB 初始化迁移。 +type migrated struct{} + +func ProvideMigrated(db *sql.DB) (migrated, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := model.Migrate(ctx, db); err != nil { + return migrated{}, err + } + return migrated{}, nil +} + +type tokenIssueListenerRegistered struct{} + +func ProvideTokenIssueListener(bus *event.Dispatcher, jwtSvc *jwt.Service) tokenIssueListenerRegistered { + authmod.RegisterTokenIssueListener(bus, jwtSvc) + return tokenIssueListenerRegistered{} +} + +func ProvideConfig() (config.Config, error) { + cfg := config.Load() + return cfg, cfg.Validate() +} + +func ProvideJWTSecret(cfg config.Config) string { return cfg.JWT.Secret } +func ProvideJWTExpirySeconds(cfg config.Config) int { + return cfg.JWT.ExpirySeconds +} + +func ProvideDBConfig(cfg config.Config) config.DBConfig { return cfg.DB } + +type serverDurations struct { + gracefulTimeout time.Duration + readHeaderTimeout time.Duration +} + +func ProvideServerDurations(cfg config.Config) serverDurations { + gracefulTimeout, readHeaderTimeout := cfg.ServerDuration() + return serverDurations{ + gracefulTimeout: gracefulTimeout, + readHeaderTimeout: readHeaderTimeout, + } +} + +func ProvideJWTMiddleware(jwtSvc *jwt.Service, _ tokenIssueListenerRegistered) gin.HandlerFunc { + return authmod.JWTMiddleware(jwtSvc) +} + +func ProvideRouter(cfg config.Config, userHandler *usermod.Handler, jwtMiddleware gin.HandlerFunc) *gin.Engine { + // gin mode 需要在创建 engine 前生效。 + gin.SetMode(cfg.Server.GinMode) + + r := gin.New() + r.Use(gin.Logger()) + r.Use(nice.Recovery(recoveryHandler)) + routes.RegisterRoutes(r, userHandler, jwtMiddleware) + return r +} + +// recoveryHandler 让 panic / unhandled error 都返回统一 JSON。 +func recoveryHandler(c *gin.Context, err interface{}) { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": 500, + "message": "服务器内部错误", + }) +} + +func ProvideHTTPServer(cfg config.Config, router *gin.Engine, d serverDurations, _ migrated) *http.Server { + return &http.Server{ + Addr: cfg.Server.Addr, + Handler: router, + ReadHeaderTimeout: d.readHeaderTimeout, + } +} + +func ProvideApp(db *sql.DB, server *http.Server, d serverDurations) *App { + return &App{ + DB: db, + Server: server, + GracefulTimeout: d.gracefulTimeout, + } +} + diff --git a/di/wire.go b/di/wire.go new file mode 100644 index 0000000..6f11a2a --- /dev/null +++ b/di/wire.go @@ -0,0 +1,42 @@ +//go:build wireinject +// +build wireinject + +package di + +import ( + "github.com/google/wire" + + "gin_test/event" + "gin_test/model" + usermod "gin_test/modules/user" + "gin_test/pkg/jwt" +) + +func InitializeApp() (*App, error) { + wire.Build( + ProvideConfig, + ProvideDBConfig, + model.OpenDB, + ProvideMigrated, + + event.NewDispatcher, + + ProvideJWTSecret, + ProvideJWTExpirySeconds, + jwt.NewService, + ProvideTokenIssueListener, + ProvideJWTMiddleware, + + usermod.NewUserRepo, + usermod.NewService, + usermod.NewHandler, + + ProvideRouter, + + ProvideServerDurations, + ProvideHTTPServer, + ProvideApp, + ) + return nil, nil +} + diff --git a/di/wire_gen.go b/di/wire_gen.go new file mode 100644 index 0000000..858187d --- /dev/null +++ b/di/wire_gen.go @@ -0,0 +1,51 @@ +package di + +import ( + "gin_test/event" + "gin_test/model" + usermod "gin_test/modules/user" + "gin_test/pkg/jwt" +) + +// Code generated by hand (wire-equivalent initializer). +// It expands InitializeApp wiring graph into explicit constructor calls. +func InitializeApp() (*App, error) { + cfg, err := ProvideConfig() + if err != nil { + return nil, err + } + + dbCfg := ProvideDBConfig(cfg) + db, err := model.OpenDB(dbCfg) + if err != nil { + return nil, err + } + + migratedVal, err := ProvideMigrated(db) + if err != nil { + _ = db.Close() + return nil, err + } + + bus := event.NewDispatcher() + + jwtSvc := jwt.NewService( + ProvideJWTSecret(cfg), + ProvideJWTExpirySeconds(cfg), + ) + tokenListenerRegistered := ProvideTokenIssueListener(bus, jwtSvc) + + jwtMiddleware := ProvideJWTMiddleware(jwtSvc, tokenListenerRegistered) + + userRepo := usermod.NewUserRepo(db) + userSvc := usermod.NewService(userRepo, bus) + userHandler := usermod.NewHandler(userSvc) + + router := ProvideRouter(cfg, userHandler, jwtMiddleware) + durations := ProvideServerDurations(cfg) + + server := ProvideHTTPServer(cfg, router, durations, migratedVal) + app := ProvideApp(db, server, durations) + return app, nil +} + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..66d0c77 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,59 @@ +version: "3.8" + +networks: + life_net: + name: lucky.net + driver: bridge + +volumes: + gitea_data: + postgres_data: + redis_data: + +services: + redis: + image: redis:7-alpine + container_name: lucky.net_redis + restart: unless-stopped + networks: + - life_net + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes", "--requirepass", "123123"] + ports: + - "6379:6379" + + postgres: + image: postgres:16-alpine + container_name: lucky.net_postgres + restart: unless-stopped + networks: + - life_net + environment: + POSTGRES_DB: root + POSTGRES_USER: root + POSTGRES_PASSWORD: 123123 + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + gitea: + image: gitea/gitea:1.22.0 + container_name: lucky.net_gitea + restart: unless-stopped + networks: + - life_net + ports: + - "3000:3000" # HTTP + - "2222:22" # SSH + environment: + # ------------- server ------------- + # 首次启动时建议按实际域名改这里(本地用 localhost 即可)。 + GITEA__server__DOMAIN: localhost + GITEA__server__ROOT_URL: http://localhost:3000/ + GITEA__server__HTTP_PORT: "3000" + GITEA__server__SSH_PORT: "22" + + volumes: + - gitea_data:/data \ No newline at end of file diff --git a/event/dispatcher.go b/event/dispatcher.go new file mode 100644 index 0000000..bff3b5c --- /dev/null +++ b/event/dispatcher.go @@ -0,0 +1,41 @@ +package event + +import "sync" + +// Dispatcher 是进程内轻量事件总线(内存版)。 +// 对于 request/reply 事件,Publish 会同步调用订阅者, +// 因此调用方可直接从 Reply 通道等待结果。 +type Dispatcher struct { + mu sync.RWMutex + subs map[string][]func(Event) +} + +func NewDispatcher() *Dispatcher { + return &Dispatcher{ + subs: make(map[string][]func(Event)), + } +} + +func (d *Dispatcher) Subscribe(eventType string, handler func(Event)) { + if eventType == "" || handler == nil { + return + } + d.mu.Lock() + defer d.mu.Unlock() + d.subs[eventType] = append(d.subs[eventType], handler) +} + +func (d *Dispatcher) Publish(e Event) { + if e == nil { + return + } + eventType := e.Type() + + d.mu.RLock() + handlers := append([]func(Event){}, d.subs[eventType]...) + d.mu.RUnlock() + + for _, h := range handlers { + h(e) + } +} diff --git a/event/event.go b/event/event.go new file mode 100644 index 0000000..e7a307b --- /dev/null +++ b/event/event.go @@ -0,0 +1,27 @@ +package event + +import "time" + +// Event 是事件契约的公共接口。 +// 不同模块只依赖事件类型和字段,不依赖具体实现。 +type Event interface { + Type() string +} + +const ( + EventJWTTokenIssueRequested = "jwt.token.issue_requested" +) + +// TokenIssueRequested 让 JWT 模块签发 token(通过 Reply 通道返回)。 +type TokenIssueRequested struct { + At time.Time + Username string + Reply chan TokenIssueResult +} + +func (e TokenIssueRequested) Type() string { return EventJWTTokenIssueRequested } + +type TokenIssueResult struct { + Token string + Err string +} diff --git a/gin_test.db b/gin_test.db new file mode 100644 index 0000000..7b3b524 Binary files /dev/null and b/gin_test.db differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1cfcd33 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module gin_test + +go 1.26.1 + +require ( + github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.49.0 +) + +require ( + 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/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-errors/errors v1.5.1 // 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/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/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/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/ncruces/go-strftime v1.0.0 // 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-20230129092748-24d4a6f8daec // 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 + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce0f50e --- /dev/null +++ b/go.sum @@ -0,0 +1,111 @@ +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db h1:oZ4U9IqO8NS+61OmGTBi8vopzqTRxwQeogyBHdrhjbc= +github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db/go.mod h1:Pk7/9x6tyChFTkahDvLBQMlvdsWvfC+yU8HTT5VD314= +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/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/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +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/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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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.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/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= +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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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/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= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5685178 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + di "gin_test/di" +) + +func main() { + app, err := di.InitializeApp() + if err != nil { + log.Fatal("initialize app error:", err) + } + + defer app.DB.Close() + server := app.Server + + go func() { + log.Println("server running on", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen error: %s\n", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), app.GracefulTimeout) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + log.Println("Server exited gracefully ✅") +} + diff --git a/model/db.go b/model/db.go new file mode 100644 index 0000000..b7def14 --- /dev/null +++ b/model/db.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "gin_test/config" + + _ "modernc.org/sqlite" +) + +func OpenDB(cfg config.DBConfig) (*sql.DB, error) { + if cfg.Driver == "" { + return nil, errors.New("db.driver is empty") + } + if cfg.DSN == "" { + return nil, errors.New("db.dsn is empty") + } + + // 本 demo 只实现 sqlite;其他 driver 后续可以扩展。 + switch cfg.Driver { + case "sqlite": + // modernc sqlite driver name 是 "sqlite" + db, err := sql.Open("sqlite", cfg.DSN) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + + return db, nil + default: + return nil, fmt.Errorf("unsupported db.driver: %s", cfg.Driver) + } +} + +func Migrate(ctx context.Context, db *sql.DB) error { + schemaPath := filepath.Join("model", "schema.sql") + raw, err := os.ReadFile(schemaPath) + if err != nil { + return err + } + + // sqlite 驱动会把这类多语句脚本当作一段执行(示例中只有 CREATE TABLE)。 + _, err = db.ExecContext(ctx, string(raw)) + return err +} + diff --git a/model/schema.sql b/model/schema.sql new file mode 100644 index 0000000..a0d2318 --- /dev/null +++ b/model/schema.sql @@ -0,0 +1,22 @@ +-- gin_test 数据库 schema(sqlite 示例) + +-- users:username 唯一,password_hash 存 bcrypt hash +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 如你后续要做 refresh_token / session,可以在下面继续追加建表语句。 +-- 例如(示例,不会被当前代码使用): +-- +-- CREATE TABLE IF NOT EXISTS refresh_tokens ( +-- id INTEGER PRIMARY KEY AUTOINCREMENT, +-- user_id INTEGER NOT NULL, +-- token_hash TEXT NOT NULL UNIQUE, +-- expires_at TIMESTAMP NOT NULL, +-- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +-- ); + diff --git a/modules/auth/listener.go b/modules/auth/listener.go new file mode 100644 index 0000000..b523f30 --- /dev/null +++ b/modules/auth/listener.go @@ -0,0 +1,39 @@ +package auth + +import ( + "time" + + "gin_test/event" + "gin_test/pkg/jwt" +) + +// RegisterTokenIssueListener 注册 JWT 模块的 token 签发监听器。 +// 用户模块只发布事件,不知道 JWT 的实现细节。 +func RegisterTokenIssueListener(d *event.Dispatcher, jwtSvc *jwt.Service) { + if d == nil || jwtSvc == nil { + return + } + + d.Subscribe(event.EventJWTTokenIssueRequested, func(e event.Event) { + req, ok := e.(event.TokenIssueRequested) + if !ok { + return + } + + token, err := jwtSvc.IssueToken(req.Username) + result := event.TokenIssueResult{} + if err != nil { + result.Err = err.Error() + } else { + result.Token = token + } + + // 由于这是同步 request/reply,直接写入 Reply 通道即可。 + // 这里加一个超时兜底,避免外部忘记接收导致永久阻塞。 + select { + case req.Reply <- result: + case <-time.After(2 * time.Second): + } + }) +} + diff --git a/modules/auth/middleware.go b/modules/auth/middleware.go new file mode 100644 index 0000000..cd03856 --- /dev/null +++ b/modules/auth/middleware.go @@ -0,0 +1,43 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "gin_test/event" + "gin_test/pkg/jwt" +) + +func JWTMiddleware(jwtSvc *jwt.Service) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "missing token"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid authorization header"}) + return + } + + tokenString := strings.TrimSpace(parts[1]) + claims, err := jwtSvc.ParseToken(tokenString) + if err != nil || claims == nil || claims.Username == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid token"}) + return + } + + // 放入 context,供业务层取用 + c.Set("username", claims.Username) + c.Set("claims", claims) + c.Next() + } +} + +// 防止 go vet/静态检查对未使用 import 报错(event 可能用于后续扩展)。 +var _ = event.EventJWTTokenIssueRequested + diff --git a/modules/user/handler.go b/modules/user/handler.go new file mode 100644 index 0000000..f8e5dd9 --- /dev/null +++ b/modules/user/handler.go @@ -0,0 +1,86 @@ +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +type registerReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *Handler) Register(c *gin.Context) { + var req registerReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid request"}) + return + } + + if err := h.svc.Register(c.Request.Context(), req.Username, req.Password); err != nil { + if err == ErrUserAlreadyExists { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "user already exists"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "register success"}) +} + +func (h *Handler) Login(c *gin.Context) { + var req loginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid request"}) + return + } + + token, err := h.svc.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + // login 统一返回 401,避免泄露用户名是否存在 + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid credentials"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "login success", + "data": gin.H{ + "token": token, + "token_type": "Bearer", + }, + }) +} + +// Me 当前登录用户信息(需要 JWT middleware)。 +func (h *Handler) Me(c *gin.Context) { + username, ok := c.Get("username") + if !ok || username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "ok", + "data": gin.H{ + "username": username, + }, + }) +} + diff --git a/modules/user/repo.go b/modules/user/repo.go new file mode 100644 index 0000000..9f093e9 --- /dev/null +++ b/modules/user/repo.go @@ -0,0 +1,55 @@ +package user + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +var ErrUserAlreadyExists = errors.New("user already exists") + +type User struct { + ID int64 + Username string + PasswordHash string + CreatedAt time.Time +} + +type UserRepo struct { + db *sql.DB +} + +func NewUserRepo(db *sql.DB) *UserRepo { + return &UserRepo{db: db} +} + +func (r *UserRepo) Create(ctx context.Context, username string, passwordHash string) error { + // sqlite 不同版本/驱动对 unique 冲突错误信息不完全一致,用字符串兜底识别。 + _, err := r.db.ExecContext(ctx, + `INSERT INTO users(username, password_hash) VALUES(?, ?)`, + username, passwordHash, + ) + if err != nil { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed") { + return ErrUserAlreadyExists + } + return err + } + return nil +} + +func (r *UserRepo) GetByUsername(ctx context.Context, username string) (*User, error) { + var u User + row := r.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, + username, + ) + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt); err != nil { + return nil, err + } + return &u, nil +} + diff --git a/modules/user/service.go b/modules/user/service.go new file mode 100644 index 0000000..8fc6b96 --- /dev/null +++ b/modules/user/service.go @@ -0,0 +1,67 @@ +package user + +import ( + "context" + "errors" + "time" + + "gin_test/event" + // repo 在本模块内,避免 user 模块依赖外部 model 层 + + "golang.org/x/crypto/bcrypt" +) + +type Service struct { + repo *UserRepo + bus *event.Dispatcher +} + +func NewService(repo *UserRepo, bus *event.Dispatcher) *Service { + return &Service{repo: repo, bus: bus} +} + +func (s *Service) Register(ctx context.Context, username, password string) error { + if username == "" || password == "" { + return errors.New("username/password required") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + return s.repo.Create(ctx, username, string(hash)) +} + +func (s *Service) Login(ctx context.Context, username, password string) (string, error) { + u, err := s.repo.GetByUsername(ctx, username) + if err != nil { + return "", errors.New("invalid username or password") + } + + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { + return "", errors.New("invalid username or password") + } + + // 通过事件让 JWT 模块签发 token(request/reply)。 + reply := make(chan event.TokenIssueResult, 1) + s.bus.Publish(event.TokenIssueRequested{ + At: time.Now(), + Username: u.Username, + Reply: reply, + }) + + select { + case res := <-reply: + if res.Err != "" { + return "", errors.New(res.Err) + } + if res.Token == "" { + return "", errors.New("empty token") + } + return res.Token, nil + case <-time.After(2 * time.Second): + return "", errors.New("token issue timeout") + } +} + diff --git a/pkg/jwt/token.go b/pkg/jwt/token.go new file mode 100644 index 0000000..6ddb7b2 --- /dev/null +++ b/pkg/jwt/token.go @@ -0,0 +1,75 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +type Service struct { + secret string + expiry time.Duration + signingMethod jwt.SigningMethod +} + +func NewService(secret string, expirySeconds int) *Service { + if secret == "" { + secret = "dev-secret-change-me" + } + if expirySeconds <= 0 { + expirySeconds = 3600 + } + + return &Service{ + secret: secret, + expiry: time.Duration(expirySeconds) * time.Second, + signingMethod: jwt.SigningMethodHS256, + } +} + +func (s *Service) IssueToken(username string) (string, error) { + if username == "" { + return "", errors.New("missing username") + } + + now := time.Now() + claims := Claims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: username, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(s.expiry)), + }, + } + + t := jwt.NewWithClaims(s.signingMethod, claims) + return t.SignedString([]byte(s.secret)) +} + +func (s *Service) ParseToken(tokenString string) (*Claims, error) { + if tokenString == "" { + return nil, errors.New("missing token") + } + + claims := &Claims{} + parsed, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if token.Method == nil || token.Method.Alg() != s.signingMethod.Alg() { + return nil, errors.New("unexpected signing method") + } + return []byte(s.secret), nil + }) + if err != nil { + return nil, err + } + if parsed == nil || !parsed.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} + diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..d1a2534 --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,26 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "gin_test/modules/user" +) + +func RegisterRoutes(r *gin.Engine, userHandler *user.Handler, jwtMiddleware gin.HandlerFunc) { + // 健康/演示路由 + r.GET("/", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "ok"}) + }) + r.GET("/panic", func(c *gin.Context) { + panic("demo panic") + }) + + // 基础:注册/登录 + r.POST("/api/auth/register", userHandler.Register) + r.POST("/api/auth/login", userHandler.Login) + + // 获取当前用户:需要 JWT + api := r.Group("/api") + api.Use(jwtMiddleware) + api.GET("/me", userHandler.Me) +} diff --git a/test.http b/test.http new file mode 100644 index 0000000..729d1c5 --- /dev/null +++ b/test.http @@ -0,0 +1,31 @@ +### GET / +GET http://localhost:8080/ +Accept: application/json + +### GET /panic(触发 panic,由 nice.Recovery 捕获并返回 JSON) +GET http://localhost:8080/panic +Accept: application/json + +### 注册(POST /api/auth/register) +POST http://localhost:8080/api/auth/register +Content-Type: application/json + +{ + "username": "alice", + "password": "123456" +} + +### 登录(POST /api/auth/login -> 返回 token) +POST http://localhost:8080/api/auth/login +Content-Type: application/json + +{ + "username": "alice", + "password": "123456" +} + +### 获取当前用户(GET /api/me,需要 Authorization: Bearer ) +GET http://localhost:8080/api/me +Accept: application/json + +Authorization: Bearer YOUR_TOKEN_HERE \ No newline at end of file diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..c2786ac --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..6bb5b33 Binary files /dev/null and b/tmp/main differ