├── migration ├── 000001_init_schema.down.sql └── 000001_init_schema.up.sql ├── config ├── config.go ├── router_white_list.go ├── constant.go └── environment_variable.go ├── internal ├── constant │ └── constant.go ├── api │ ├── handler │ │ └── ping.go │ ├── router.go │ └── v1 │ │ └── user.go ├── database │ ├── pre_detect_database.go │ ├── redis.go │ └── postgre.go ├── middleware │ ├── middleware.go │ ├── recover.go │ ├── context_user_info.go │ ├── ip_limit_rate.go │ ├── print_http_log.go │ ├── alarm.go │ └── authorize.go ├── repository │ └── user.go ├── model │ └── user.go └── service │ └── user.go ├── .env.example ├── pkg ├── util │ ├── rabbitMQ │ │ ├── index.go │ │ ├── model.go │ │ ├── README.md │ │ ├── consumer.go │ │ └── producer.go │ ├── crypto │ │ └── md5.go │ ├── snowFlake │ │ └── snow_flake.go │ ├── typeConversion │ │ └── type_conversion.go │ ├── customBindValidator │ │ ├── register_bind_validation.go │ │ └── custom_bind_validator.go │ ├── realIP │ │ └── real_ip.go │ ├── fastTime │ │ └── fast_time.go │ ├── userAgentParser │ │ └── user_agent_parser.go │ ├── response │ │ └── response.go │ ├── uuid │ │ └── uuid.go │ └── formatValidator │ │ ├── user_info_validator.go │ │ └── user_info_validator_test.go └── auth │ └── token.go ├── .gitignore ├── test ├── ping_test.go └── user_test.go ├── LICENSE ├── cmd └── api │ └── main.go ├── go.mod ├── Makefile ├── project_bootstrap.sh ├── doc └── README.zh-CN.md ├── README.md └── go.sum /migration/000001_init_schema.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists app_user; 2 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // @Title config.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:52 4 | 5 | package config 6 | -------------------------------------------------------------------------------- /internal/constant/constant.go: -------------------------------------------------------------------------------- 1 | // @Title constant.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:46 4 | 5 | package constant 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # postgresql 2 | DB_HOST= 3 | DB_PORT= 4 | DB_USER= 5 | DB_PASSWORD= 6 | DB_DATABASE_NAME= 7 | 8 | # redis 9 | REDIS_HOST= 10 | REDIS_PORT= 11 | REDIS_PASSWORD= 12 | REDIS_DB=0 13 | -------------------------------------------------------------------------------- /config/router_white_list.go: -------------------------------------------------------------------------------- 1 | // @Title router_white_list.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 22:05 4 | 5 | package config 6 | 7 | var RouterWhiteList = map[string]string{ 8 | "/api/ping": "GET", 9 | "/api/v1/user/auth": "POST,PUT", 10 | } 11 | -------------------------------------------------------------------------------- /internal/api/handler/ping.go: -------------------------------------------------------------------------------- 1 | // @Title ping.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:36 4 | 5 | package api 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func Ping(c *gin.Context) { 14 | c.String(http.StatusOK, "pong") 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /pkg/util/rabbitMQ/index.go: -------------------------------------------------------------------------------- 1 | // @Title index.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:04 4 | 5 | package rabbitMQ 6 | 7 | import "go-gin-api-starter/config" 8 | 9 | var amqpURI string 10 | 11 | var reliable bool 12 | 13 | func init() { 14 | amqpURI = config.MessageQueueConfig.Uri 15 | reliable = false 16 | } 17 | -------------------------------------------------------------------------------- /config/constant.go: -------------------------------------------------------------------------------- 1 | // @Title constant.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:47 4 | 5 | package config 6 | 7 | // CommonSplicePrefix Common prefixes for splicing, such as token, redis key, etc., to distinguish different projects 8 | const CommonSplicePrefix = "go-gin-api-starter" 9 | 10 | // NodeEnv current running environment 11 | const ( 12 | Development = "development" 13 | Production = "production" 14 | Test = "test" 15 | ) 16 | -------------------------------------------------------------------------------- /pkg/util/crypto/md5.go: -------------------------------------------------------------------------------- 1 | // @Title md5.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:34 4 | 5 | package crypto 6 | 7 | import ( 8 | "crypto/md5" 9 | "fmt" 10 | ) 11 | 12 | // Md5 13 | // @Description: MD5 14 | // @param originMessage origin message 15 | // @return result encrypted message 16 | func Md5(originMessage string) (result string) { 17 | data := []byte(originMessage) 18 | result = fmt.Sprintf("%x", md5.Sum(data)) 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /pkg/util/snowFlake/snow_flake.go: -------------------------------------------------------------------------------- 1 | // @Title snow_flake.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:23 4 | 5 | package snowFlake 6 | 7 | import "github.com/bwmarrin/snowflake" 8 | 9 | var ( 10 | Node *snowflake.Node 11 | ) 12 | 13 | func init() { 14 | node, err := snowflake.NewNode(0) 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | Node = node 20 | } 21 | 22 | func GenStringID() string { 23 | return Node.Generate().String() 24 | } 25 | -------------------------------------------------------------------------------- /internal/database/pre_detect_database.go: -------------------------------------------------------------------------------- 1 | // @Title pre_detect_database.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 11:26 4 | 5 | package database 6 | 7 | import ( 8 | "fmt" 9 | ) 10 | 11 | func PreDetectDatabase() error { 12 | if err := preDetectPostgres(); err != nil { 13 | return err 14 | } 15 | 16 | if err := preDetectRedis(); err != nil { 17 | return err 18 | } 19 | 20 | fmt.Println("All database connections are successful.") 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS X files 2 | .DS_Store 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache 18 | .glide/ 19 | 20 | # Dependency directories 21 | vendor/ 22 | 23 | # IDE files 24 | .idea/ 25 | .vscode/ 26 | 27 | # Environment variables file 28 | .env 29 | .env.development 30 | .env.production 31 | -------------------------------------------------------------------------------- /pkg/util/typeConversion/type_conversion.go: -------------------------------------------------------------------------------- 1 | // @Title type_conversion.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 20:29 4 | 5 | package typeConversion 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "io" 11 | ) 12 | 13 | func StructToReader(s interface{}) (io.Reader, error) { 14 | data, err := json.Marshal(s) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return bytes.NewReader(data), nil 19 | } 20 | 21 | func StringToStruct(data string, s interface{}) error { 22 | return json.Unmarshal([]byte(data), s) 23 | } 24 | -------------------------------------------------------------------------------- /test/ping_test.go: -------------------------------------------------------------------------------- 1 | // @Title ping_test.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 19:48 4 | 5 | package test 6 | 7 | import ( 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "go-gin-api-starter/internal/api" 14 | ) 15 | 16 | func TestPing(t *testing.T) { 17 | router := api.SetUpRouter() 18 | 19 | w := httptest.NewRecorder() 20 | req, _ := http.NewRequest(http.MethodGet, "/api/ping", nil) 21 | router.ServeHTTP(w, req) 22 | 23 | assert.Equal(t, http.StatusOK, w.Code) 24 | assert.Equal(t, "pong", w.Body.String()) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/util/rabbitMQ/model.go: -------------------------------------------------------------------------------- 1 | // @Title model.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:06 4 | 5 | package rabbitMQ 6 | 7 | import "github.com/streadway/amqp" 8 | 9 | type MessageHandler func(message []byte) error 10 | 11 | type consumerClient struct { 12 | conn *amqp.Connection 13 | channel *amqp.Channel 14 | tag string 15 | done chan error 16 | } 17 | 18 | type connection struct { 19 | conn *amqp.Connection 20 | channels map[string]*amqp.Channel 21 | defaultChannel *amqp.Channel 22 | exchange string 23 | exchangeType string 24 | err chan error 25 | } 26 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | // @Title middleware.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:34 4 | 5 | package middleware 6 | 7 | import ( 8 | "github.com/gin-contrib/cors" 9 | "github.com/gin-gonic/gin" 10 | "go-gin-api-starter/config" 11 | ) 12 | 13 | func SetupMiddleware(r *gin.Engine) { 14 | r.Use(cors.Default()) 15 | 16 | r.Use(limitIP(20)) 17 | 18 | r.Use(authorize()) 19 | 20 | // global recover 21 | r.Use(globalRecover()) 22 | 23 | // print http request and response in development and test environments 24 | if config.NodeEnv != config.Production { 25 | r.Use(printHTTPLog()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /migration/000001_init_schema.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists app_user 2 | ( 3 | id bigserial primary key, 4 | account_name varchar(255) unique not null, -- 'account name' 5 | password varchar(255) not null, -- 'password' 6 | created_at timestamp with time zone default CURRENT_TIMESTAMP not null, -- 'create time' 7 | updated_at timestamp with time zone default CURRENT_TIMESTAMP not null, -- 'update time' 8 | is_deleted smallint default 0 not null -- 'is deleted', 9 | ); 10 | 11 | insert into app_user (account_name, password) 12 | values ('hunter', '69a329523ce1ec88bf63061863d9cb14'); -------------------------------------------------------------------------------- /pkg/util/customBindValidator/register_bind_validation.go: -------------------------------------------------------------------------------- 1 | // @Title register_bind_validation.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:20 4 | 5 | package customBindValidator 6 | 7 | import ( 8 | "github.com/gin-gonic/gin/binding" 9 | "github.com/go-playground/validator/v10" 10 | ) 11 | 12 | func Register() (err error) { 13 | if v, ok := binding.Validator.Engine().(*validator.Validate); ok { 14 | err = v.RegisterValidation("isPhoneNumber", isMobileNumber) 15 | if err != nil { 16 | return 17 | } 18 | 19 | err = v.RegisterValidation("isEmail", isEmail) 20 | if err != nil { 21 | return 22 | } 23 | 24 | err = v.RegisterValidation("isAccountName", isAccountName) 25 | if err != nil { 26 | return 27 | } 28 | } 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /pkg/util/realIP/real_ip.go: -------------------------------------------------------------------------------- 1 | // @Title real_ip.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:42 4 | 5 | package realIP 6 | 7 | import ( 8 | "net" 9 | "strings" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func GetRealIP(c *gin.Context) string { 15 | // First, try to get IP using c.ClientIP() 16 | ip := c.ClientIP() 17 | 18 | // If c.ClientIP() returns an internal IP, empty string, or invalid format, 19 | // then check X-Real-IP header 20 | if ip == "" || ip == "::1" || strings.HasPrefix(ip, "127.") || !isValidIP(ip) { 21 | realIP := c.GetHeader("X-Real-IP") 22 | if realIP != "" && isValidIP(realIP) { 23 | ip = realIP 24 | } 25 | } 26 | 27 | return ip 28 | } 29 | 30 | // Helper function: check if the IP is valid 31 | func isValidIP(ip string) bool { 32 | return net.ParseIP(ip) != nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/middleware/recover.go: -------------------------------------------------------------------------------- 1 | // @Title globalRecover.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:25 4 | 5 | package middleware 6 | 7 | import ( 8 | "net/http" 9 | "runtime/debug" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/sirupsen/logrus" 13 | "go-gin-api-starter/pkg/util/response" 14 | ) 15 | 16 | // globalRecover 17 | // @Description: global globalRecover 18 | // @return gin.HandlerFunc 19 | func globalRecover() gin.HandlerFunc { 20 | return func(c *gin.Context) { 21 | defer func() { 22 | if r := recover(); r != nil { 23 | stack := debug.Stack() 24 | logrus.Errorf("Panic recovered: %v\nStack Trace:\n%s", r, string(stack)) 25 | 26 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 27 | "code": response.StatusInternalServerError, 28 | "message": "Internal Server Error", 29 | }) 30 | } 31 | }() 32 | 33 | c.Next() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/middleware/context_user_info.go: -------------------------------------------------------------------------------- 1 | // @Title context_user_info.go 2 | // @Description Get userinfo from context and format it. 3 | // @Author Hunter 2024/9/4 10:42 4 | 5 | package middleware 6 | 7 | import ( 8 | "errors" 9 | 10 | "github.com/gin-gonic/gin" 11 | "go-gin-api-starter/pkg/auth" 12 | ) 13 | 14 | // GetContextUserInfo 15 | // @Description: get userinfo from context and format it 16 | // @param c gin.Context 17 | // @return contextUserInfo formatted userinfo 18 | // @return err return when error, otherwise nil 19 | func GetContextUserInfo(c *gin.Context) (contextUserInfo auth.ContextUserInfo, exists bool, err error) { 20 | userInfoInterface, exists := c.Get("userInfo") 21 | if !exists { 22 | return 23 | } 24 | 25 | contextUserInfo, ok := userInfoInterface.(auth.ContextUserInfo) 26 | if !ok { 27 | err = errors.New("failed to convert contextUserInfo format") 28 | return 29 | } 30 | 31 | return 32 | } 33 | -------------------------------------------------------------------------------- /internal/repository/user.go: -------------------------------------------------------------------------------- 1 | // @Title user.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 16:04 4 | 5 | package repository 6 | 7 | import ( 8 | "go-gin-api-starter/internal/model" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type UserRepository struct { 13 | db *gorm.DB 14 | } 15 | 16 | func NewUserRepository(db *gorm.DB) *UserRepository { 17 | return &UserRepository{db: db} 18 | } 19 | 20 | func (r *UserRepository) GetUserByAccountName(accountName string) (*model.User, error) { 21 | var user model.User 22 | result := r.db.Where("account_name = ?", accountName).First(&user) 23 | if result.Error != nil { 24 | return nil, result.Error 25 | } 26 | return &user, nil 27 | } 28 | 29 | func (r *UserRepository) GetUserByID(userID uint64) (*model.User, error) { 30 | var user model.User 31 | result := r.db.Where("id = ?", userID).First(&user) 32 | if result.Error != nil { 33 | return nil, result.Error 34 | } 35 | return &user, nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/util/customBindValidator/custom_bind_validator.go: -------------------------------------------------------------------------------- 1 | // @Title custom_bind_validator.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:18 4 | 5 | package customBindValidator 6 | 7 | import ( 8 | "github.com/go-playground/validator/v10" 9 | "go-gin-api-starter/pkg/util/formatValidator" 10 | ) 11 | 12 | var isMobileNumber validator.Func = func(fl validator.FieldLevel) bool { 13 | text := fl.Field().String() 14 | if err := formatValidator.ValidateMobileNumber(text); err != nil { 15 | return false 16 | } 17 | return true 18 | } 19 | 20 | var isEmail validator.Func = func(fl validator.FieldLevel) bool { 21 | text := fl.Field().String() 22 | if err := formatValidator.ValidateEmail(text); err != nil { 23 | return false 24 | } 25 | return true 26 | } 27 | 28 | var isAccountName validator.Func = func(fl validator.FieldLevel) bool { 29 | text := fl.Field().String() 30 | if err := formatValidator.ValidateAccountName(text); err != nil { 31 | return false 32 | } 33 | return true 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hunter Ji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pkg/util/fastTime/fast_time.go: -------------------------------------------------------------------------------- 1 | // @Title fast_time.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:24 4 | 5 | package fastTime 6 | 7 | import ( 8 | "sync/atomic" 9 | "time" 10 | ) 11 | 12 | func init() { 13 | go func() { 14 | ticker := time.NewTicker(time.Second) 15 | defer ticker.Stop() 16 | for tm := range ticker.C { 17 | t := uint64(tm.Unix()) 18 | atomic.StoreUint64(¤tTimestamp, t) 19 | } 20 | }() 21 | } 22 | 23 | var currentTimestamp = uint64(time.Now().Unix()) 24 | 25 | // UnixTimestamp returns the current unix timestamp in seconds. 26 | // 27 | // It is faster than time.Now().Unix() 28 | func UnixTimestamp() uint64 { 29 | return atomic.LoadUint64(¤tTimestamp) 30 | } 31 | 32 | // UnixDate returns date from the current unix timestamp. 33 | // 34 | // The date is calculated by dividing unix timestamp by (24*3600) 35 | func UnixDate() uint64 { 36 | return UnixTimestamp() / (24 * 3600) 37 | } 38 | 39 | // UnixHour returns hour from the current unix timestamp. 40 | // 41 | // The hour is calculated by dividing unix timestamp by 3600 42 | func UnixHour() uint64 { 43 | return UnixTimestamp() / 3600 44 | } 45 | -------------------------------------------------------------------------------- /internal/api/router.go: -------------------------------------------------------------------------------- 1 | // @Title router.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:10 4 | 5 | package api 6 | 7 | import ( 8 | "github.com/gin-gonic/gin" 9 | api "go-gin-api-starter/internal/api/handler" 10 | v1 "go-gin-api-starter/internal/api/v1" 11 | "go-gin-api-starter/internal/database" 12 | "go-gin-api-starter/internal/middleware" 13 | "go-gin-api-starter/internal/repository" 14 | "go-gin-api-starter/internal/service" 15 | ) 16 | 17 | func SetUpRouter() *gin.Engine { 18 | r := gin.Default() 19 | 20 | middleware.SetupMiddleware(r) 21 | 22 | LoadRouter(r) 23 | 24 | return r 25 | } 26 | 27 | func LoadRouter(e *gin.Engine) { 28 | r := e.Group("/api") 29 | { 30 | r.GET("/ping", api.Ping) 31 | loadUserRouter(r) 32 | } 33 | } 34 | 35 | func loadUserRouter(e *gin.RouterGroup) { 36 | userRepo := repository.NewUserRepository(database.DB) 37 | userService := service.NewUserService(userRepo) 38 | userHandler := v1.NewUserHandler(userService) 39 | 40 | r := e.Group("/v1/user") 41 | { 42 | r.POST("/auth", userHandler.Login) 43 | r.PUT("/auth", userHandler.RefreshToken) 44 | r.DELETE("/auth", userHandler.Logout) 45 | r.GET("/:id", userHandler.GetUserInfo) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/util/userAgentParser/user_agent_parser.go: -------------------------------------------------------------------------------- 1 | // @Title user_agent_parser.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:32 4 | 5 | package userAgentParser 6 | 7 | import ( 8 | "errors" 9 | 10 | "github.com/mileusna/useragent" 11 | ) 12 | 13 | type Device struct { 14 | Browser string `db:"browser" json:"browser"` 15 | BrowserVersion string `db:"browserVersion" json:"browserVersion"` 16 | OS string `db:"os" json:"os"` 17 | OSVersion string `db:"osVersion" json:"osVersion"` 18 | } 19 | 20 | // GetDeviceInfo 21 | // @Description: parse user-agent and get request's device info 22 | // @param userAgent user-agent 23 | // @return d device info 24 | // @return isBot the request was initiated by a script this time 25 | // @return err 26 | func GetDeviceInfo(userAgent string) (d Device, isBot bool, err error) { 27 | ua := useragent.Parse(userAgent) 28 | 29 | d.Browser = ua.Name 30 | d.BrowserVersion = ua.Version 31 | d.OS = ua.OS 32 | d.OSVersion = ua.OSVersion 33 | 34 | isBot = ua.Bot 35 | if isBot { 36 | return 37 | } 38 | 39 | if d.Browser == "" || d.BrowserVersion == "" || d.OS == "" || d.OSVersion == "" { 40 | err = errors.New("failed to parse user-agent") 41 | return 42 | } 43 | 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /internal/database/redis.go: -------------------------------------------------------------------------------- 1 | // @Title redis.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:26 4 | 5 | package database 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/go-redis/redis/v8" 13 | "go-gin-api-starter/config" 14 | ) 15 | 16 | var ( 17 | RDB *redis.Client 18 | rdbHost, rdbPort, rdbPassword string 19 | rdbDB int 20 | ) 21 | 22 | func init() { 23 | rdbHost = config.RedisConfig.Host 24 | rdbPort = config.RedisConfig.Port 25 | rdbPassword = config.RedisConfig.Password 26 | rdbDB = config.RedisConfig.DB 27 | 28 | fmt.Printf("Redis: %s:%s\n\n", rdbHost, rdbPort) 29 | 30 | RedisConnect() 31 | } 32 | 33 | // RedisConnect 34 | // Description: Connect to Redis database and return the client 35 | // @return rdb Connection client 36 | func RedisConnect() { 37 | RDB = redis.NewClient(&redis.Options{ 38 | Addr: fmt.Sprintf("%s:%s", rdbHost, rdbPort), 39 | Password: rdbPassword, 40 | DB: rdbDB, 41 | }) 42 | } 43 | 44 | func preDetectRedis() error { 45 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 46 | defer cancel() 47 | 48 | _, err := RDB.Ping(ctx).Result() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | // @Title main.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 16:51 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "os" 11 | 12 | "github.com/gin-gonic/gin" 13 | "go-gin-api-starter/internal/api" 14 | "go-gin-api-starter/internal/database" 15 | "go-gin-api-starter/pkg/util/customBindValidator" 16 | ) 17 | 18 | const defaultPort = "9000" 19 | 20 | func main() { 21 | if err := run(); err != nil { 22 | log.Fatalf("Application failed to start: %v", err) 23 | } 24 | } 25 | 26 | func run() error { 27 | // Pre-detect database 28 | if err := database.PreDetectDatabase(); err != nil { 29 | return fmt.Errorf("failed to pre-detect database: %w", err) 30 | } 31 | 32 | // Initialize router 33 | r := configureGinEngine() 34 | 35 | // Start server 36 | port := getPort() 37 | log.Printf("Starting server on port %s", port) 38 | return r.Run("0.0.0.0:" + port) 39 | } 40 | 41 | func configureGinEngine() *gin.Engine { 42 | r := api.SetUpRouter() 43 | 44 | if err := customBindValidator.Register(); err != nil { 45 | log.Fatalf("Failed to register custom validator: %v", err) 46 | } 47 | 48 | return r 49 | } 50 | 51 | func getPort() string { 52 | if port, exists := os.LookupEnv("NODE_PORT"); exists { 53 | return port 54 | } 55 | return defaultPort 56 | } 57 | -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | // @Title user.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 15:57 4 | 5 | package model 6 | 7 | import ( 8 | "time" 9 | 10 | "gorm.io/plugin/soft_delete" 11 | ) 12 | 13 | type User struct { 14 | ID uint64 `json:"id" gorm:"column:id;primary_id"` 15 | AccountName string `json:"account_name" gorm:"column:account_name"` 16 | Password string `json:"password" gorm:"column:password"` 17 | CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` 18 | UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` 19 | IsDeleted soft_delete.DeletedAt `json:"-" gorm:"column:is_deleted;softDelete:flag"` 20 | } 21 | 22 | func (u User) TableName() string { 23 | return "app_user" 24 | } 25 | 26 | type LoginRequest struct { 27 | AccountName string `json:"accountName" binding:"required,max=255"` 28 | Password string `json:"password" binding:"required,max=255"` 29 | } 30 | 31 | type RefreshTokenRequest struct { 32 | RefreshToken string `json:"refreshToken" binding:"required,max=1000"` 33 | } 34 | 35 | type AuthResponse struct { 36 | AccessToken string `json:"accessToken"` 37 | RefreshToken string `json:"refreshToken"` 38 | } 39 | 40 | type GetUserInfoRequest struct { 41 | ID uint64 `uri:"id" binding:"required,min=1"` 42 | } 43 | -------------------------------------------------------------------------------- /pkg/util/rabbitMQ/README.md: -------------------------------------------------------------------------------- 1 | ## Publish 2 | 3 | ```go 4 | type JobMessageType struct { 5 | ID uint64 `redis:"ID" json:"ID"` 6 | UID uint64 `redis:"UID" json:"UID"` 7 | ActionID uint64 `redis:"actionID" json:"actionID"` 8 | FileID string `redis:"fileID" json:"fileID"` 9 | Params string `redis:"params" json:"params"` 10 | } 11 | 12 | func PublishJobMessage(message JobMessageType) (err error) { 13 | messageByte, err := json.Marshal(message) 14 | if err != nil { 15 | return 16 | } 17 | 18 | err = rabbitMQ.Publish( 19 | envVariable.MessageQueueConfig.JobExchangeName, 20 | envVariable.MessageQueueConfig.JobExchangeType, 21 | "", 22 | messageByte, 23 | ) 24 | return 25 | } 26 | ``` 27 | 28 | ## Consume 29 | 30 | ```go 31 | // event_audit_consumer.go 32 | func eventAuditConsumer() { 33 | // subscribe event_audit direct 34 | rabbitMQ.Consumer( 35 | envVariable.MessageQueueConfig.EventAuditExchangeName, 36 | envVariable.MessageQueueConfig.EventAuditExchangeType, 37 | envVariable.MessageQueueConfig.EventAuditQueue, 38 | "event_audit", 39 | eventAuditService.MessageHandler, 40 | ) 41 | } 42 | 43 | // consumer.go 44 | func Consumer() { 45 | go eventAuditConsumer() 46 | } 47 | 48 | // main.go 49 | func main() { 50 | // other code... 51 | 52 | go Consumer() 53 | 54 | // Run gin server 55 | _ = r.Run("0.0.0.0:" + port) 56 | } 57 | ``` -------------------------------------------------------------------------------- /internal/middleware/ip_limit_rate.go: -------------------------------------------------------------------------------- 1 | // @Title ip_limit_rate.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:36 4 | 5 | package middleware 6 | 7 | import ( 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/go-redis/redis_rate/v9" 13 | "go-gin-api-starter/config" 14 | "go-gin-api-starter/internal/database" 15 | "go-gin-api-starter/pkg/util/response" 16 | ) 17 | 18 | // limitIP 19 | // @Description: limit the access frequency of each ip per second, intercept it if the access frequency exceeds the set threshold 20 | // @param perSecondCount every ip is allowed to access the number of times per second 21 | // @return gin.HandlerFunc 22 | func limitIP(perSecondCount int) gin.HandlerFunc { 23 | return func(c *gin.Context) { 24 | rdb := database.RDB 25 | limiter := redis_rate.NewLimiter(rdb) 26 | 27 | res, err := limiter.Allow( 28 | c, 29 | fmt.Sprintf("%s-IPLimitRate:%s", config.CommonSplicePrefix, c.ClientIP()), 30 | redis_rate.PerSecond(perSecondCount), 31 | ) 32 | if err != nil { 33 | fmt.Println("rate limit error : ", err) 34 | c.AbortWithStatusJSON(http.StatusOK, gin.H{ 35 | "code": response.StatusErr, 36 | "message": "failed to limit ip rate", 37 | }) 38 | return 39 | } 40 | 41 | if res.Allowed == 0 { 42 | c.AbortWithStatusJSON(http.StatusOK, gin.H{ 43 | "code": response.StatusErr, 44 | "message": "too frequent operation", 45 | }) 46 | return 47 | } 48 | 49 | c.Next() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/database/postgre.go: -------------------------------------------------------------------------------- 1 | // @Title postgre.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:29 4 | 5 | package database 6 | 7 | import ( 8 | "fmt" 9 | "time" 10 | 11 | "go-gin-api-starter/config" 12 | "gorm.io/driver/postgres" 13 | "gorm.io/gorm" 14 | "gorm.io/gorm/logger" 15 | "gorm.io/gorm/schema" 16 | ) 17 | 18 | var ( 19 | DB *gorm.DB 20 | dbHost, dbPort, dbUser, dbPassword, dbName string 21 | ) 22 | 23 | func init() { 24 | dbHost = config.DBConfig.Host 25 | dbPort = config.DBConfig.Port 26 | dbUser = config.DBConfig.User 27 | dbPassword = config.DBConfig.Password 28 | dbName = config.DBConfig.DBName 29 | 30 | fmt.Printf("Database: %s@%s:%s\n", dbUser, dbHost, dbPort) 31 | 32 | initDB() 33 | } 34 | 35 | func initDB() { 36 | dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", 37 | dbHost, dbUser, dbPassword, dbName, dbPort) 38 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 39 | Logger: logger.Default.LogMode(logger.Info), 40 | NamingStrategy: schema.NamingStrategy{ 41 | SingularTable: true, 42 | NoLowerCase: true, 43 | }, 44 | }) 45 | 46 | if err != nil { 47 | panic(fmt.Errorf("failed to connect to PostgreSQL: %v", err)) 48 | } 49 | DB = db 50 | 51 | sqlDB, err := DB.DB() 52 | if err != nil { 53 | panic(fmt.Errorf("failed to get underlying *sql.DB: %v", err)) 54 | } 55 | 56 | // Configure connection pool 57 | sqlDB.SetMaxIdleConns(10) 58 | sqlDB.SetMaxOpenConns(100) 59 | sqlDB.SetConnMaxLifetime(time.Hour) 60 | } 61 | 62 | func preDetectPostgres() error { 63 | d, err := DB.DB() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if err := d.Ping(); err != nil { 69 | return err 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/service/user.go: -------------------------------------------------------------------------------- 1 | // @Title user.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 16:10 4 | 5 | package service 6 | 7 | import ( 8 | "errors" 9 | 10 | "github.com/sirupsen/logrus" 11 | "go-gin-api-starter/internal/database" 12 | "go-gin-api-starter/internal/model" 13 | "go-gin-api-starter/internal/repository" 14 | "go-gin-api-starter/pkg/auth" 15 | "go-gin-api-starter/pkg/util/crypto" 16 | ) 17 | 18 | type UserService struct { 19 | userRepo *repository.UserRepository 20 | } 21 | 22 | func NewUserService(userRepo *repository.UserRepository) *UserService { 23 | return &UserService{userRepo: userRepo} 24 | } 25 | 26 | func (s *UserService) Login(req *model.LoginRequest) (*model.AuthResponse, error) { 27 | user, err := s.userRepo.GetUserByAccountName(req.AccountName) 28 | if err != nil { 29 | logrus.Errorf("failed to get user by account name: %v", err) 30 | return nil, err 31 | } 32 | 33 | if user.Password != crypto.Md5(req.Password) { 34 | logrus.Errorf("invalid password") 35 | return nil, errors.New("invalid password") 36 | } 37 | 38 | contextUserInfo := auth.ContextUserInfo{ 39 | UserID: user.ID, 40 | } 41 | 42 | accessToken, refreshToken, err := auth.GenerateAccessTokenAndRefreshToken(contextUserInfo, database.RDB) 43 | if err != nil { 44 | logrus.Errorf("failed to generate access token and refresh token: %v", err) 45 | return nil, err 46 | } 47 | 48 | resp := &model.AuthResponse{ 49 | AccessToken: accessToken, 50 | RefreshToken: refreshToken, 51 | } 52 | 53 | return resp, nil 54 | } 55 | 56 | func (s *UserService) Logout(userID uint64) error { 57 | return auth.DeleteToken(userID, database.RDB) 58 | } 59 | 60 | func (s *UserService) RefreshToken(refreshToken string) (string, string, error) { 61 | newAccessToken, newRefreshToken, err := auth.RefreshToken(refreshToken, database.RDB) 62 | if err != nil { 63 | return "", "", err 64 | } 65 | 66 | return newAccessToken, newRefreshToken, nil 67 | } 68 | 69 | func (s *UserService) GetUserByID(userID uint64) (*model.User, error) { 70 | user, err := s.userRepo.GetUserByID(userID) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return user, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/util/response/response.go: -------------------------------------------------------------------------------- 1 | // @Title response.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:03 4 | 5 | package response 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // Custom response code 14 | const ( 15 | StatusOK = 20000 // request success 16 | StatusErr = 20001 // general error 17 | StatusNotModified = 30004 // no change 18 | StatusBadRequest = 40000 // bad request 19 | StatusUnauthorized = 40001 // need to authenticate 20 | StatusInternalServerError = 50000 // internal server error 21 | StatusErrToken = 50008 // token error 22 | StatusRepeatLogin = 50012 // duplicate login 23 | StatusExpireToken = 50014 // token expired 24 | ) 25 | 26 | type CustomResponse[T any] struct { 27 | Code int `json:"code"` 28 | Message string `json:"message,omitempty"` 29 | Data T `json:"data,omitempty"` 30 | } 31 | 32 | // Data sends a successful response with the provided data 33 | // @param c *gin.Context 34 | // @param data interface{} - The data to be returned in the response 35 | func Data[T any](c *gin.Context, data T) { 36 | c.JSON(http.StatusOK, CustomResponse[T]{ 37 | Code: StatusOK, 38 | Data: data, 39 | }) 40 | } 41 | 42 | // Error sends an error response with the provided message 43 | // @param c *gin.Context 44 | // @param message string - The error message to be returned 45 | func Error(c *gin.Context, message string) { 46 | c.JSON(http.StatusOK, CustomResponse[string]{ 47 | Code: StatusErr, 48 | Message: message, 49 | Data: "", 50 | }) 51 | } 52 | 53 | // Code sends a response with a specific status code and its corresponding message 54 | // @param c *gin.Context 55 | // @param code int - The custom status code 56 | func Code(c *gin.Context, code int) { 57 | codeMap := map[int]string{ 58 | StatusOK: "", 59 | StatusNotModified: "", 60 | StatusBadRequest: "Bad Request", 61 | StatusUnauthorized: "Authentication Required", 62 | StatusErrToken: "Token Error", 63 | StatusRepeatLogin: "Duplicate Login", 64 | StatusExpireToken: "Token Expired", 65 | } 66 | 67 | c.JSON(http.StatusOK, CustomResponse[string]{ 68 | Code: code, 69 | Message: codeMap[code], 70 | Data: "", 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/util/uuid/uuid.go: -------------------------------------------------------------------------------- 1 | // @Title uuid.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:10 4 | 5 | package uuid 6 | 7 | import ( 8 | "crypto/rand" 9 | "fmt" 10 | "io" 11 | "os" 12 | "syscall" 13 | "time" 14 | ) 15 | 16 | const ( 17 | // Bits is the number of bits in a UUID 18 | Bits = 128 19 | 20 | // Size is the number of bytes in a UUID 21 | Size = Bits / 8 22 | 23 | format = "%08x%04x%04x%04x%012x" 24 | ) 25 | 26 | var ( 27 | // Loggerf can be used to override the default logging target. 28 | // Log messages in this library should be logged at warning level or higher. 29 | Loggerf = func(format string, args ...interface{}) {} 30 | ) 31 | 32 | // UUID represents a UUID value. UUIDs can be compared and set to other values and accessed by byte. 33 | type UUID [Size]byte 34 | 35 | // GenerateUUID creates a new UUID 36 | // @return u UUID 37 | func GenerateUUID() (u UUID) { 38 | const ( 39 | maxretries = 9 40 | backoff = time.Millisecond * 10 41 | ) 42 | 43 | var ( 44 | totalBackoff time.Duration 45 | count int 46 | retries int 47 | ) 48 | 49 | for { 50 | b := time.Duration(retries) * backoff 51 | time.Sleep(b) 52 | totalBackoff += b 53 | 54 | n, err := io.ReadFull(rand.Reader, u[count:]) 55 | if err != nil { 56 | if retryOnError(err) && retries < maxretries { 57 | count += n 58 | retries++ 59 | Loggerf("error generating version 4 uuid, retrying: %v", err) 60 | continue 61 | } 62 | 63 | panic(fmt.Errorf("error reading random number generator, retried for %v: %v", totalBackoff.String(), err)) 64 | } 65 | 66 | break 67 | } 68 | 69 | // Set the version (4) and variant fields 70 | u[6] = (u[6] & 0x0f) | 0x40 71 | u[8] = (u[8] & 0x3f) | 0x80 72 | 73 | return u 74 | } 75 | 76 | // String formats the UUID as a string 77 | // @receiver u UUID 78 | // @return string formatted UUID string 79 | func (u UUID) String() string { 80 | return fmt.Sprintf(format, u[:4], u[4:6], u[6:8], u[8:10], u[10:]) 81 | } 82 | 83 | // retryOnError attempts to detect if a retry would be effective 84 | // @param err error 85 | // @return bool whether retry might be effective 86 | func retryOnError(err error) bool { 87 | switch err := err.(type) { 88 | case *os.PathError: 89 | return retryOnError(err.Err) // unpack the target error 90 | case syscall.Errno: 91 | if err == syscall.EPERM { 92 | return true 93 | } 94 | } 95 | 96 | return false 97 | } 98 | -------------------------------------------------------------------------------- /internal/middleware/print_http_log.go: -------------------------------------------------------------------------------- 1 | // @Title print_http_log.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 11:07 4 | 5 | package middleware 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/sanity-io/litter" 15 | ) 16 | 17 | const ( 18 | maxLogLength = 200 // Maximum length for request/response logging 19 | ) 20 | 21 | type bodyLogWriter struct { 22 | gin.ResponseWriter 23 | body *bytes.Buffer 24 | } 25 | 26 | func (w *bodyLogWriter) Write(b []byte) (int, error) { 27 | w.body.Write(b) 28 | return w.ResponseWriter.Write(b) 29 | } 30 | 31 | func truncateString(s string, maxLength int) string { 32 | if len(s) <= maxLength { 33 | return s 34 | } 35 | return s[:maxLength] + "..." 36 | } 37 | 38 | // LogEntry represents a structured log entry 39 | type LogEntry struct { 40 | Timestamp string `json:"timestamp"` 41 | Method string `json:"method"` 42 | URL string `json:"url"` 43 | StatusCode int `json:"status_code"` 44 | UserInfo interface{} `json:"user_info,omitempty"` 45 | Request string `json:"request"` 46 | Response string `json:"response"` 47 | } 48 | 49 | func printHTTPLog() gin.HandlerFunc { 50 | return func(c *gin.Context) { 51 | var requestBody string 52 | 53 | // Check if the request body is not nil 54 | if c.Request.Body != nil { 55 | var buf bytes.Buffer 56 | tee := io.TeeReader(c.Request.Body, &buf) 57 | body, err := io.ReadAll(tee) 58 | if err != nil { 59 | fmt.Printf("Error reading request body: %v\n", err) 60 | } else { 61 | requestBody = string(body) 62 | c.Request.Body = io.NopCloser(&buf) 63 | } 64 | } 65 | 66 | // Capture the response body 67 | blw := &bodyLogWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer} 68 | c.Writer = blw 69 | 70 | c.Next() 71 | 72 | logEntry := LogEntry{ 73 | Timestamp: time.Now().Format("2006-01-02 15:04:05"), 74 | Method: c.Request.Method, 75 | URL: c.Request.URL.String(), 76 | StatusCode: c.Writer.Status(), 77 | Request: truncateString(requestBody, maxLogLength), 78 | Response: truncateString(blw.body.String(), maxLogLength), 79 | } 80 | 81 | // Log user info if available 82 | userInfo, exists, err := GetContextUserInfo(c) 83 | if exists { 84 | if err != nil { 85 | logEntry.UserInfo = map[string]string{"error": err.Error()} 86 | } else { 87 | logEntry.UserInfo = userInfo 88 | } 89 | } 90 | 91 | fmt.Println() 92 | litter.Dump(logEntry) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/util/rabbitMQ/consumer.go: -------------------------------------------------------------------------------- 1 | // @Title consumer.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:06 4 | 5 | package rabbitMQ 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "strings" 11 | 12 | "github.com/streadway/amqp" 13 | "go-gin-api-starter/config" 14 | "go-gin-api-starter/pkg/util/fastTime" 15 | "go-gin-api-starter/pkg/util/uuid" 16 | ) 17 | 18 | var clientID = fmt.Sprintf("%s-%s-%d", config.CommonSplicePrefix, uuid.GenerateUUID(), fastTime.UnixTimestamp()) 19 | 20 | func Consumer(exchangeName, exchangeType, queueName, routingKey string, messageHandler MessageHandler) { 21 | c := &consumerClient{ 22 | conn: nil, 23 | channel: nil, 24 | tag: clientID, 25 | } 26 | 27 | var err error 28 | c.conn, err = amqp.Dial(amqpURI) 29 | if err != nil { 30 | log.Fatalf("connect failed : %s", err) 31 | } 32 | defer c.conn.Close() 33 | 34 | c.channel, err = c.conn.Channel() 35 | if err != nil { 36 | log.Fatalf("ch error : %s", err) 37 | } 38 | defer c.channel.Close() 39 | 40 | if !strings.HasPrefix(exchangeName, "amq.") { 41 | err = c.channel.ExchangeDeclare( 42 | exchangeName, 43 | exchangeType, 44 | true, 45 | false, 46 | false, 47 | false, 48 | nil, 49 | ) 50 | if err != nil { 51 | log.Fatalf("exchange declare error : %s", err) 52 | } 53 | } 54 | 55 | queue, err := c.channel.QueueDeclare( 56 | queueName, 57 | true, 58 | false, 59 | false, 60 | false, 61 | nil, 62 | ) 63 | if err != nil { 64 | log.Fatalf("Queue Declare: %s", err) 65 | } 66 | 67 | if err = c.channel.QueueBind( 68 | queue.Name, 69 | routingKey, 70 | exchangeName, 71 | false, 72 | nil, 73 | ); err != nil { 74 | log.Fatalf("Queue Bind: %s", err) 75 | } 76 | 77 | deliveries, err := c.channel.Consume( 78 | queue.Name, 79 | c.tag, 80 | false, 81 | false, 82 | false, 83 | false, 84 | nil, 85 | ) 86 | if err != nil { 87 | log.Fatalf("Queue Consume: %s", err) 88 | } 89 | 90 | forever := make(chan bool) 91 | 92 | go func() { 93 | for d := range deliveries { 94 | log.Printf("Received a message: %s\n", d.Body) 95 | 96 | err := messageHandler(d.Body) 97 | if err != nil { 98 | fmt.Printf("failed to handle message in custom exchange: %s, error: %s\n", exchangeName, err) 99 | } 100 | 101 | if err == nil { 102 | d.Ack(false) 103 | } 104 | } 105 | }() 106 | 107 | log.Printf(" [consumerClient] Waiting for messages. To exit press CTRL+C") 108 | <-forever 109 | } 110 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go-gin-api-starter 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/bwmarrin/snowflake v0.3.0 7 | github.com/gin-contrib/cors v1.7.2 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/go-playground/validator/v10 v10.20.0 10 | github.com/go-redis/redis/v8 v8.11.5 11 | github.com/go-redis/redis_rate/v9 v9.1.2 12 | github.com/golang-jwt/jwt/v5 v5.2.1 13 | github.com/joho/godotenv v1.5.1 14 | github.com/mileusna/useragent v1.3.4 15 | github.com/sanity-io/litter v1.5.5 16 | github.com/sirupsen/logrus v1.9.3 17 | github.com/streadway/amqp v1.1.0 18 | github.com/stretchr/testify v1.9.0 19 | gorm.io/driver/postgres v1.5.9 20 | gorm.io/gorm v1.25.11 21 | gorm.io/plugin/soft_delete v1.2.1 22 | ) 23 | 24 | require ( 25 | github.com/bytedance/sonic v1.11.6 // indirect 26 | github.com/bytedance/sonic/loader v0.1.1 // indirect 27 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 28 | github.com/cloudwego/base64x v0.1.4 // indirect 29 | github.com/cloudwego/iasm v0.2.0 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 32 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 33 | github.com/gin-contrib/sse v0.1.0 // indirect 34 | github.com/go-playground/locales v0.14.1 // indirect 35 | github.com/go-playground/universal-translator v0.18.1 // indirect 36 | github.com/goccy/go-json v0.10.2 // indirect 37 | github.com/jackc/pgpassfile v1.0.0 // indirect 38 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 39 | github.com/jackc/pgx/v5 v5.5.5 // indirect 40 | github.com/jackc/puddle/v2 v2.2.1 // indirect 41 | github.com/jinzhu/inflection v1.0.0 // indirect 42 | github.com/jinzhu/now v1.1.5 // indirect 43 | github.com/json-iterator/go v1.1.12 // indirect 44 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 45 | github.com/kr/text v0.2.0 // indirect 46 | github.com/leodido/go-urn v1.4.0 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 51 | github.com/pmezard/go-difflib v1.0.0 // indirect 52 | github.com/rogpeppe/go-internal v1.12.0 // indirect 53 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 54 | github.com/ugorji/go/codec v1.2.12 // indirect 55 | golang.org/x/arch v0.8.0 // indirect 56 | golang.org/x/crypto v0.23.0 // indirect 57 | golang.org/x/net v0.25.0 // indirect 58 | golang.org/x/sync v0.1.0 // indirect 59 | golang.org/x/sys v0.20.0 // indirect 60 | golang.org/x/text v0.15.0 // indirect 61 | google.golang.org/protobuf v1.34.1 // indirect 62 | gopkg.in/yaml.v3 v3.0.1 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /config/environment_variable.go: -------------------------------------------------------------------------------- 1 | // @Title environment_variable.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:04 4 | 5 | package config 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | 13 | "github.com/joho/godotenv" 14 | ) 15 | 16 | // NodeEnv current running environment 17 | var NodeEnv = Development 18 | 19 | var TokenConfig struct { 20 | AccessTokenSecret string 21 | RefreshTokenSecret string 22 | } 23 | 24 | var DBConfig struct { 25 | Host string 26 | Port string 27 | User string 28 | Password string 29 | DBName string 30 | } 31 | 32 | var RedisConfig struct { 33 | Host string 34 | Port string 35 | Password string 36 | DB int 37 | } 38 | 39 | // MessageQueueConfig rabbitMQ 40 | var MessageQueueConfig struct { 41 | Uri string 42 | JobExchangeName string 43 | JobExchangeType string 44 | } 45 | 46 | func init() { 47 | env := os.Getenv("NODE_ENV") 48 | if env != "" { 49 | NodeEnv = env 50 | } 51 | 52 | projectRoot, err := findProjectRoot() 53 | if err != nil { 54 | panic(fmt.Errorf("failed to find project root: %w", err)) 55 | } 56 | 57 | envFile := filepath.Join(projectRoot, fmt.Sprintf(".env.%s", NodeEnv)) 58 | 59 | fmt.Printf("Loading env file: %s\n", envFile) 60 | 61 | if err := godotenv.Load(envFile); err != nil { 62 | panic(fmt.Errorf("failed to load env file: %w", err)) 63 | } 64 | 65 | // Token 66 | TokenConfig.AccessTokenSecret = os.Getenv("ACCESS_TOKEN_SECRET") 67 | TokenConfig.RefreshTokenSecret = os.Getenv("REFRESH_TOKEN_SECRET") 68 | 69 | // DB 70 | DBConfig.Host = os.Getenv("DB_HOST") 71 | DBConfig.Port = os.Getenv("DB_PORT") 72 | DBConfig.User = os.Getenv("DB_USER") 73 | DBConfig.Password = os.Getenv("DB_PASSWORD") 74 | DBConfig.DBName = os.Getenv("DB_DATABASE_NAME") 75 | 76 | // Redis 77 | RedisConfig.Host = os.Getenv("REDIS_HOST") 78 | RedisConfig.Port = os.Getenv("REDIS_PORT") 79 | RedisConfig.Password = os.Getenv("REDIS_PASSWORD") 80 | redisDB, err := strconv.Atoi(os.Getenv("REDIS_DB")) 81 | if err != nil { 82 | panic(err) 83 | } 84 | RedisConfig.DB = redisDB 85 | 86 | /* 87 | // MessageQueue 88 | MessageQueueConfig.Uri = os.Getenv("MESSAGE_QUEUE_ADDRESS") 89 | MessageQueueConfig.JobExchangeName = os.Getenv("MESSAGE_QUEUE_JOB_EXCHANGE_NAME") 90 | MessageQueueConfig.JobExchangeType = os.Getenv("MESSAGE_QUEUE_JOB_EXCHANGE_TYPE") 91 | */ 92 | } 93 | 94 | func findProjectRoot() (string, error) { 95 | dir, err := os.Getwd() 96 | if err != nil { 97 | return "", err 98 | } 99 | 100 | for { 101 | if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { 102 | return dir, nil 103 | } 104 | 105 | parent := filepath.Dir(dir) 106 | if parent == dir { 107 | return "", fmt.Errorf("could not find go.mod file") 108 | } 109 | dir = parent 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/api/v1/user.go: -------------------------------------------------------------------------------- 1 | // @Title user.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 16:10 4 | 5 | package v1 6 | 7 | import ( 8 | "github.com/gin-gonic/gin" 9 | "github.com/sirupsen/logrus" 10 | "go-gin-api-starter/internal/middleware" 11 | "go-gin-api-starter/internal/model" 12 | "go-gin-api-starter/internal/service" 13 | "go-gin-api-starter/pkg/util/response" 14 | ) 15 | 16 | type UserHandler struct { 17 | userService *service.UserService 18 | } 19 | 20 | func NewUserHandler(userService *service.UserService) *UserHandler { 21 | return &UserHandler{userService: userService} 22 | } 23 | 24 | func (h *UserHandler) Login(c *gin.Context) { 25 | var loginReq model.LoginRequest 26 | if err := c.ShouldBindJSON(&loginReq); err != nil { 27 | response.Code(c, response.StatusBadRequest) 28 | return 29 | } 30 | 31 | resp, err := h.userService.Login(&loginReq) 32 | if err != nil { 33 | response.Error(c, "login failed") 34 | return 35 | } 36 | 37 | response.Data(c, resp) 38 | } 39 | 40 | func (h *UserHandler) Logout(c *gin.Context) { 41 | userinfo, _, err := middleware.GetContextUserInfo(c) 42 | if err != nil { 43 | logrus.Errorf("failed to get userinfo: %v\n", err) 44 | response.Error(c, "get userinfo failed") 45 | return 46 | } 47 | 48 | err = h.userService.Logout(userinfo.UserID) 49 | if err != nil { 50 | logrus.Errorf("failed to logout: %v\n", err) 51 | response.Error(c, "logout failed") 52 | return 53 | } 54 | 55 | response.Data(c, "logout success") 56 | } 57 | 58 | func (h *UserHandler) RefreshToken(c *gin.Context) { 59 | var refreshTokenReq model.RefreshTokenRequest 60 | if err := c.ShouldBindJSON(&refreshTokenReq); err != nil { 61 | response.Code(c, response.StatusBadRequest) 62 | return 63 | } 64 | 65 | newAccessToken, newRefreshToken, err := h.userService.RefreshToken(refreshTokenReq.RefreshToken) 66 | if err != nil { 67 | response.Error(c, "refresh token failed") 68 | return 69 | } 70 | 71 | response.Data(c, model.AuthResponse{ 72 | AccessToken: newAccessToken, 73 | RefreshToken: newRefreshToken, 74 | }) 75 | } 76 | 77 | func (h *UserHandler) GetUserInfo(c *gin.Context) { 78 | var getUserInfoReq model.GetUserInfoRequest 79 | if err := c.ShouldBindUri(&getUserInfoReq); err != nil { 80 | response.Code(c, response.StatusBadRequest) 81 | return 82 | } 83 | 84 | userinfo, _, err := middleware.GetContextUserInfo(c) 85 | if err != nil { 86 | response.Error(c, "get userinfo failed") 87 | return 88 | } 89 | 90 | if getUserInfoReq.ID != userinfo.UserID { 91 | response.Error(c, "only your own information can be obtained") 92 | return 93 | } 94 | 95 | resp, err := h.userService.GetUserByID(userinfo.UserID) 96 | if err != nil { 97 | logrus.Errorf("failed to get user info: %v", err) 98 | response.Error(c, "get user info failed") 99 | return 100 | } 101 | 102 | response.Data(c, resp) 103 | } 104 | -------------------------------------------------------------------------------- /internal/middleware/alarm.go: -------------------------------------------------------------------------------- 1 | // @Title alarm.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 10:26 4 | 5 | package middleware 6 | 7 | import ( 8 | "encoding/json" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "time" 13 | 14 | "github.com/sirupsen/logrus" 15 | "go-gin-api-starter/config" 16 | ) 17 | 18 | // errorString 19 | // @Description: error message 20 | type errorString struct { 21 | s string 22 | } 23 | 24 | // errorInfo 25 | // @Description: error detail message 26 | type errorInfo struct { 27 | Time string `json:"time"` // time 28 | Alarm string `json:"alarm"` // alarm level 29 | Message string `json:"message"` // message 30 | Filename string `json:"filename"` // error file name 31 | Line int `json:"line"` // error line number 32 | FuncName string `json:"func_name"` // error function name 33 | } 34 | 35 | // Error 36 | // @Description: return error message 37 | // @receiver e errorString 38 | // @return string error message 39 | func (e *errorString) Error() string { 40 | return e.s 41 | } 42 | 43 | // Info 44 | // @Description: create general error 45 | // @param text error message 46 | // @return error 47 | func Info(text string) error { 48 | alarm("INFO", text, 2) 49 | return &errorString{text} 50 | } 51 | 52 | // Panic 53 | // @Description: panic error level 54 | // @param text error message 55 | // @return error 56 | func Panic(text string) error { 57 | alarm("PANIC", text, 5) 58 | return &errorString{text} 59 | } 60 | 61 | // alarm 62 | // @Description: error method 63 | // @param level error level 64 | // @param str error message 65 | // @param skip number of stack frames,0:current function,1:previous layer function,...... 66 | func alarm(level string, str string, skip int) { 67 | // 当前时间 68 | currentTime := time.Now().Format("2006-01-02 15:04:05") 69 | 70 | // 定义 文件名、行号、方法名 71 | functionName := "?" 72 | 73 | pc, fileName, line, ok := runtime.Caller(skip) 74 | if ok { 75 | functionName = runtime.FuncForPC(pc).Name() 76 | functionName = filepath.Ext(functionName) 77 | functionName = strings.TrimPrefix(functionName, ".") 78 | } 79 | 80 | var msg = errorInfo{ 81 | Time: currentTime, 82 | Alarm: level, 83 | Message: str, 84 | Filename: fileName, 85 | Line: line, 86 | FuncName: functionName, 87 | } 88 | 89 | messageJson, errs := json.Marshal(msg) 90 | if errs != nil { 91 | logrus.Errorf("json marshal error: %v", errs) 92 | } 93 | 94 | errorJsonInfo := string(messageJson) 95 | logrus.Error(errorJsonInfo) 96 | 97 | if level == "INFO" { 98 | // do something 99 | } else if level == "PANIC" { 100 | if config.NodeEnv == "production" { 101 | // publish message 102 | /* 103 | message := messageQueue.Message{ 104 | Node: "BrightWord", 105 | Status: "error", 106 | Title: msg.Filename, 107 | Content: errorJsonInfo, 108 | RunTime: msg.Time, 109 | } 110 | if err := message.Publish(); err != nil { 111 | fmt.Println("failed to publish message : ", err) 112 | } 113 | */ 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Go parameters 2 | GOCMD=go 3 | GOBUILD=$(GOCMD) build 4 | GORUN=$(GOCMD) run 5 | GOCLEAN=$(GOCMD) clean 6 | GOTEST=$(GOCMD) test 7 | GOGET=$(GOCMD) get 8 | GOMOD=$(GOCMD) mod 9 | BINARY_NAME=go-gin-api-starter 10 | BINARY_UNIX=$(BINARY_NAME)_linux 11 | MAIN_PATH=cmd/api/main.go 12 | 13 | # Build flags 14 | BUILD_FLAGS=-v 15 | 16 | .PHONY: all build run clean test test-verbose test-folder test-folder-verbose test-file test-file-verbose coverage deps lint help build-linux build-linux-mac-intel build-linux-mac-arm build-linux-win 17 | 18 | all: test build 19 | 20 | build: 21 | $(GOBUILD) $(BUILD_FLAGS) -o $(BINARY_NAME) $(MAIN_PATH) 22 | 23 | run: 24 | $(GORUN) $(MAIN_PATH) 25 | 26 | clean: 27 | $(GOCLEAN) 28 | rm -f $(BINARY_NAME) 29 | rm -f $(BINARY_UNIX) 30 | 31 | # Run tests without verbose output 32 | test: 33 | $(GOTEST) ./... 34 | 35 | # Run tests with verbose output 36 | test-verbose: 37 | $(GOTEST) -v ./... 38 | 39 | # Run tests in ./test folder without verbose output 40 | test-folder: 41 | $(GOTEST) ./test/... 42 | 43 | # Run tests in ./test folder with verbose output 44 | test-folder-verbose: 45 | $(GOTEST) -v ./test/... 46 | 47 | # Run a specific test file without verbose output 48 | test-file: 49 | @if [ -z "$(FILE)" ]; then \ 50 | echo "Please specify a test file using FILE=path/to/your_test.go"; \ 51 | else \ 52 | $(GOTEST) $(FILE); \ 53 | fi 54 | 55 | # Run a specific test file with verbose output 56 | test-file-verbose: 57 | @if [ -z "$(FILE)" ]; then \ 58 | echo "Please specify a test file using FILE=path/to/your_test.go"; \ 59 | else \ 60 | $(GOTEST) -v $(FILE); \ 61 | fi 62 | 63 | coverage: 64 | $(GOTEST) -coverprofile=coverage.out ./... 65 | $(GOCMD) tool cover -html=coverage.out 66 | 67 | deps: 68 | $(GOGET) -v -t -d ./... 69 | $(GOMOD) tidy 70 | 71 | lint: 72 | golangci-lint run 73 | 74 | # Build for Linux on Linux 75 | build-linux: 76 | GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILD_FLAGS) -o $(BINARY_UNIX) $(MAIN_PATH) 77 | 78 | # Build for Linux on macOS (Intel) 79 | build-linux-mac-intel: 80 | GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILD_FLAGS) -o $(BINARY_UNIX) $(MAIN_PATH) 81 | 82 | # Build for Linux on macOS (Apple Silicon) 83 | build-linux-mac-arm: 84 | GOOS=linux GOARCH=arm64 $(GOBUILD) $(BUILD_FLAGS) -o $(BINARY_UNIX)_arm64 $(MAIN_PATH) 85 | 86 | # Build for Linux on Windows 87 | build-linux-win: 88 | SET GOOS=linux 89 | SET GOARCH=amd64 90 | $(GOBUILD) $(BUILD_FLAGS) -o $(BINARY_UNIX) $(MAIN_PATH) 91 | 92 | help: 93 | @echo "Available commands:" 94 | @echo " make build - Build the binary for current OS" 95 | @echo " make run - Run the application" 96 | @echo " make clean - Remove binary and cache" 97 | @echo " make test - Run all tests without verbose output" 98 | @echo " make test-verbose - Run all tests with verbose output" 99 | @echo " make test-folder - Run tests in ./test folder without verbose output" 100 | @echo " make test-folder-verbose - Run tests in ./test folder with verbose output" 101 | @echo " make test-file FILE=path/to/your_test.go - Run a specific test file without verbose output" 102 | @echo " make test-file-verbose FILE=path/to/your_test.go - Run a specific test file with verbose output" 103 | @echo " make coverage - Run tests with coverage" 104 | @echo " make deps - Download dependencies" 105 | @echo " make lint - Run linter" 106 | @echo " make build-linux - Build for Linux on Linux" 107 | @echo " make build-linux-mac-intel - Build for Linux on macOS (Intel)" 108 | @echo " make build-linux-mac-arm - Build for Linux on macOS (Apple Silicon)" 109 | @echo " make build-linux-win - Build for Linux on Windows" 110 | -------------------------------------------------------------------------------- /internal/middleware/authorize.go: -------------------------------------------------------------------------------- 1 | // @Title authorize.go 2 | // @Description Middleware for access control, checks whitelist and validates token. And the middleware will put the user info in the context. 3 | // @Author Hunter 2024/9/4 10:41 4 | 5 | package middleware 6 | 7 | import ( 8 | "fmt" 9 | "net/http" 10 | "slices" 11 | "strings" 12 | 13 | "github.com/gin-gonic/gin" 14 | "go-gin-api-starter/config" 15 | "go-gin-api-starter/internal/database" 16 | "go-gin-api-starter/pkg/auth" 17 | "go-gin-api-starter/pkg/util/response" 18 | ) 19 | 20 | const ( 21 | ErrMsgUnauthorized = "unauthorized access" 22 | ErrMsgExpiredToken = "expired token" 23 | ) 24 | 25 | // isItOnTheWhiteList 26 | // @Description: checks if the request path and method are in the whitelist 27 | // @param path request path 28 | // @param method request method 29 | // @return bool true if in whitelist, false otherwise 30 | func isItOnTheWhiteList(path, method string) bool { 31 | if allowedMethod, ok := config.RouterWhiteList[path]; ok { 32 | if allowedMethod == "*" { 33 | return true 34 | } 35 | 36 | allowedMethods := strings.Split(allowedMethod, ",") 37 | if slices.Contains(allowedMethods, method) { 38 | return true 39 | } 40 | } 41 | return false 42 | } 43 | 44 | // respondWithError 45 | // @Description: error response 46 | // @param c gin context 47 | // @param statusCode custom status code 48 | // @param message error message 49 | func respondWithError(c *gin.Context, statusCode int, message string) { 50 | c.AbortWithStatusJSON(http.StatusOK, gin.H{ 51 | "code": statusCode, 52 | "message": message, 53 | }) 54 | } 55 | 56 | // validateToken 57 | // @Description: checks the token and gets user info from jwt 58 | // @param c gin context 59 | // @param rdb Redis client 60 | // @return *ContextUserInfo user info if token is valid 61 | // @return string new access token if token is expired 62 | // @return bool true if token is expired 63 | // @return error 64 | func validateToken(c *gin.Context) (*auth.ContextUserInfo, string, bool, error) { 65 | // Get the Authorization header 66 | authHeader := c.GetHeader("Authorization") 67 | 68 | // Check if the header is empty 69 | if authHeader == "" { 70 | return nil, "", false, fmt.Errorf("failed to get access token") 71 | } 72 | 73 | // Check if the token starts with "Bearer " 74 | if !strings.HasPrefix(authHeader, "Bearer ") { 75 | return nil, "", false, fmt.Errorf("invalid access token") 76 | } 77 | 78 | // remove prefix 79 | token := strings.TrimPrefix(authHeader, "Bearer ") 80 | 81 | claims, newAccessToken, expired, err := auth.ValidateAccessTokenAndRefresh(token, database.RDB) 82 | if err != nil { 83 | return nil, "", expired, err 84 | } 85 | 86 | return &claims.ContextUserInfo, newAccessToken, false, nil 87 | } 88 | 89 | // authorize 90 | // @Description: middleware for access control, checks whitelist and validates token 91 | // @return gin.HandlerFunc 92 | func authorize() gin.HandlerFunc { 93 | return func(c *gin.Context) { 94 | // Allow OPTIONS requests 95 | if c.Request.Method == http.MethodOptions { 96 | c.AbortWithStatusJSON(http.StatusOK, gin.H{"code": response.StatusOK}) 97 | return 98 | } 99 | 100 | // Check if request is in whitelist 101 | if !isItOnTheWhiteList(c.Request.URL.Path, c.Request.Method) { 102 | userInfo, newAccessToken, _, err := validateToken(c) 103 | if err != nil { 104 | respondWithError(c, response.StatusUnauthorized, ErrMsgUnauthorized) 105 | return 106 | } 107 | 108 | // Set new access token in header only if access token is expired 109 | if newAccessToken != "" { 110 | c.Header("Authorization", newAccessToken) 111 | } 112 | 113 | // Set user info in context 114 | c.Set("userInfo", *userInfo) 115 | } 116 | 117 | c.Next() 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /pkg/util/formatValidator/user_info_validator.go: -------------------------------------------------------------------------------- 1 | // @Title user_info_validator.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:29 4 | 5 | package formatValidator 6 | 7 | import ( 8 | "errors" 9 | "net" 10 | "regexp" 11 | ) 12 | 13 | // ValidateMobileNumber 14 | // @Description: Validates mobile phone number format using regex 15 | // @param mobileNumber The mobile phone number to validate 16 | // @return error 17 | func ValidateMobileNumber(mobileNumber string) error { 18 | pattern := `^1[3-9]\d{9}$` 19 | regex := regexp.MustCompile(pattern) 20 | if !regex.MatchString(mobileNumber) { 21 | return errors.New("invalid mobile phone number format") 22 | } 23 | return nil 24 | } 25 | 26 | // ValidateIDNumber 27 | // @Description: Validates ID card number format using regex 28 | // @param idNumber The ID card number to validate 29 | // @return error 30 | func ValidateIDNumber(idNumber string) error { 31 | pattern := `(^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$)|(^\d{6}(18|19|20)\d{2}(0[1-9]|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$)` 32 | regex := regexp.MustCompile(pattern) 33 | if !regex.MatchString(idNumber) { 34 | return errors.New("invalid ID card number format") 35 | } 36 | return nil 37 | } 38 | 39 | // ValidateEmail 40 | // @Description: Validates email address format using regex 41 | // @param email The email address to validate 42 | // @return error 43 | func ValidateEmail(email string) error { 44 | pattern := `^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$` 45 | regex := regexp.MustCompile(pattern) 46 | if !regex.MatchString(email) { 47 | return errors.New("invalid email address format") 48 | } 49 | return nil 50 | } 51 | 52 | // ValidateChineseName 53 | // @Description: Validates Chinese name format using regex 54 | // @param name The Chinese name to validate 55 | // @return error 56 | func ValidateChineseName(name string) error { 57 | pattern := `^(?:[\u4e00-\u9fa5·]{2,16})` 58 | regex := regexp.MustCompile(pattern) 59 | if !regex.MatchString(name) { 60 | return errors.New("invalid Chinese name format") 61 | } 62 | return nil 63 | } 64 | 65 | // ValidateAccountName 66 | // @Description: Validates account name format, allowing numbers, letters, and symbols 67 | // @param accountName The account name to validate 68 | // @return error 69 | func ValidateAccountName(accountName string) error { 70 | pattern := `^[[:graph:]]{1,50}$` 71 | regex := regexp.MustCompile(pattern) 72 | if !regex.MatchString(accountName) { 73 | return errors.New("invalid account name format") 74 | } 75 | return nil 76 | } 77 | 78 | // ValidateRoleName 79 | // @Description: Validates role name format, allowing Chinese characters, letters, and numbers 80 | // @param roleName The role name to validate 81 | // @return error 82 | func ValidateRoleName(roleName string) error { 83 | pattern := `^[\u4e00-\u9fa5a-zA-Z0-9]{1,50}$` 84 | regex := regexp.MustCompile(pattern) 85 | if !regex.MatchString(roleName) { 86 | return errors.New("invalid role name format") 87 | } 88 | return nil 89 | } 90 | 91 | // ValidateIPWithWildcard 92 | // @Description: Validates IP address format, allowing wildcards 93 | // @param ip The IP address to validate 94 | // @return error 95 | func ValidateIPWithWildcard(ip string) error { 96 | pattern := `^(([0,1]?\d{1,2}|2([0-4][0-9]|5[0-5]))|\*)(\.(([0,1]?\d{1,2}|2([0-4][0-9]|5[0-5]))|\*)){3}$` 97 | regex := regexp.MustCompile(pattern) 98 | if !regex.MatchString(ip) { 99 | return errors.New("invalid IP address format (with wildcard)") 100 | } 101 | return nil 102 | } 103 | 104 | // ValidateIP 105 | // @Description: Validates standard IP address format 106 | // @param ip The IP address to validate 107 | // @return error 108 | func ValidateIP(ip string) error { 109 | if net.ParseIP(ip) != nil { 110 | return nil 111 | } 112 | return errors.New("invalid IP address format") 113 | } 114 | -------------------------------------------------------------------------------- /project_bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | OLD_PROJECT_NAME="go-gin-api-starter" 4 | 5 | BOLD='\033[1m' 6 | GREEN='\033[0;32m' 7 | BLUE='\033[0;34m' 8 | YELLOW='\033[0;33m' 9 | RED='\033[0;31m' 10 | CYAN='\033[0;36m' 11 | NC='\033[0m' # No Color 12 | 13 | checkSys=$(uname -s) 14 | 15 | # welcome 16 | echo -e "${BOLD}${BLUE}🚀 Welcome to the Go Project Initializer!${NC}" 17 | 18 | # new project name 19 | echo -e "\n${YELLOW}📝 Please enter the new project name:${NC}" 20 | read -p "> " NEW_PROJECT_NAME 21 | 22 | echo -e "\n${GREEN}Initializing your project...${NC}" 23 | 24 | if [ "$checkSys" == "Darwin" ]; then 25 | # MacOS 26 | find . -type f \( -name "*.go" -o -name "go.mod" -o -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) -exec sed -i "" "s/$OLD_PROJECT_NAME/$NEW_PROJECT_NAME/g" {} + 27 | elif [ "$checkSys" == "Linux" ]; then 28 | # Linux 29 | find . -type f \( -name "*.go" -o -name "go.mod" -o -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) -exec sed -i "s/$OLD_PROJECT_NAME/$NEW_PROJECT_NAME/g" {} + 30 | else 31 | echo -e "${RED}Unsupported operating system.${NC}" 32 | exit 1 33 | fi 34 | 35 | echo -e "${GREEN}Project name updated successfully!${NC}" 36 | 37 | # remove RabbitMQ folder if not needed 38 | echo -e "\n${YELLOW}🐰 Do you need RabbitMQ in your project? [Y/n]:${NC}" 39 | read -p "> " need_rabbitmq 40 | need_rabbitmq=${need_rabbitmq:-Y} 41 | if [[ $need_rabbitmq =~ ^[Nn]$ ]]; then 42 | if [ -d "pkg/util/rabbitMQ" ]; then 43 | rm -rf pkg/util/rabbitMQ 44 | echo -e "${GREEN}RabbitMQ folder removed.${NC}" 45 | else 46 | echo -e "${BLUE}RabbitMQ folder not found. No action taken.${NC}" 47 | fi 48 | else 49 | echo -e "${BLUE}RabbitMQ folder kept intact.${NC}" 50 | fi 51 | 52 | # remove .git directory if needed 53 | echo -e "\n${YELLOW}🗑️ Do you want to remove the existing .git directory? [Y/n]:${NC}" 54 | read -p "> " remove_git 55 | remove_git=${remove_git:-Y} 56 | if [[ $remove_git =~ ^[Yy]$ ]]; then 57 | rm -rf .git 58 | echo -e "${GREEN}Existing .git directory removed.${NC}" 59 | 60 | # new Git repository 61 | echo -e "\n${YELLOW}🔧 Do you want to initialize a new Git repository? [Y/n]:${NC}" 62 | read -p "> " init_new_git 63 | init_new_git=${init_new_git:-Y} 64 | if [[ $init_new_git =~ ^[Yy]$ ]]; then 65 | git init 66 | if [ $? -eq 0 ]; then 67 | echo -e "${GREEN}New Git repository initialized successfully.${NC}" 68 | else 69 | echo -e "${RED}Failed to initialize new Git repository.${NC}" 70 | fi 71 | else 72 | echo -e "${BLUE}Skipped initializing new Git repository.${NC}" 73 | fi 74 | else 75 | echo -e "${BLUE}Existing .git directory kept intact.${NC}" 76 | fi 77 | 78 | echo -e "\n${BOLD}${GREEN}🎉 Project initialization complete!${NC}" 79 | echo -e "${BLUE}Summary:${NC}" 80 | echo -e " ${YELLOW}Old project name:${NC} $OLD_PROJECT_NAME" 81 | echo -e " ${YELLOW}New project name:${NC} $NEW_PROJECT_NAME" 82 | echo -e " ${YELLOW}RabbitMQ:${NC} $([[ $need_rabbitmq =~ ^[Yy]$ ]] && echo "Included" || echo "Removed")" 83 | echo -e " ${YELLOW}Git:${NC} $([[ $remove_git =~ ^[Yy]$ ]] && ([[ $init_new_git =~ ^[Yy]$ ]] && echo "Reinitialized" || echo "Removed") || echo "Kept existing")" 84 | 85 | # go mod tidy 86 | echo -e "\n${YELLOW}🧹 Do you want to run 'go mod tidy' to clean up dependencies? [Y/n]:${NC}" 87 | read -p "> " run_tidy 88 | run_tidy=${run_tidy:-Y} 89 | if [[ $run_tidy =~ ^[Yy]$ ]]; then 90 | echo -e "${BLUE}Running 'go mod tidy'...${NC}" 91 | go mod tidy 92 | if [ $? -eq 0 ]; then 93 | echo -e "${GREEN}Dependencies cleaned up successfully.${NC}" 94 | else 95 | echo -e "${RED}Error occurred while cleaning up dependencies.${NC}" 96 | fi 97 | else 98 | echo -e "${BLUE}Skipped 'go mod tidy'. Remember to run it later if needed.${NC}" 99 | fi 100 | 101 | # new steps 102 | echo -e "\n${BOLD}${CYAN}🚀 How to start your project:${NC}" 103 | echo -e "${CYAN}1. Modify .env.development in the project root to set Redis and PostgreSQL configurations.${NC}" 104 | echo -e "${CYAN}2. Run the following command to start your project:${NC}" 105 | echo -e " ${YELLOW}make run${NC}" 106 | 107 | echo -e "\n${BOLD}${GREEN}🎈 Your project is ready! Happy coding!${NC}" 108 | -------------------------------------------------------------------------------- /pkg/util/rabbitMQ/producer.go: -------------------------------------------------------------------------------- 1 | // @Title producer.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 18:05 4 | 5 | package rabbitMQ 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "strings" 11 | "time" 12 | 13 | "github.com/sirupsen/logrus" 14 | "github.com/streadway/amqp" 15 | ) 16 | 17 | var connectionPool map[string]*connection 18 | 19 | func createConnection(exchange, exchangeType string) *connection { 20 | c := &connection{ 21 | exchange: exchange, 22 | exchangeType: exchangeType, 23 | err: make(chan error), 24 | } 25 | go c.Listen() 26 | return c 27 | } 28 | 29 | func (c *connection) Connect() error { 30 | conn, err := amqp.Dial(amqpURI) 31 | if err != nil { 32 | logrus.Errorf("failed to create connection, reason: %s", err) 33 | return err 34 | } 35 | 36 | c.conn = conn 37 | c.channels = map[string]*amqp.Channel{} 38 | 39 | go func() { 40 | <-c.conn.NotifyClose(make(chan *amqp.Error)) // Listen to NotifyClose 41 | c.err <- errors.New("connection closed") 42 | }() 43 | 44 | c.defaultChannel, err = c.conn.Channel() 45 | if err != nil { 46 | logrus.Errorf("failed to create channel: %s", err) 47 | return err 48 | } 49 | if err := c.defaultChannel.ExchangeDeclare( 50 | c.exchange, // name 51 | c.exchangeType, // type 52 | true, // durable 53 | false, // auto-deleted 54 | false, // internal 55 | false, // noWait 56 | nil, // arguments 57 | ); err != nil { 58 | logrus.Errorf("failed to create exchange declare: %s", err) 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func publishAction(ch *amqp.Channel, exchangeName, routingKey string, message []byte) error { 66 | err := ch.Publish( 67 | exchangeName, // exchangeName 68 | routingKey, // routing routingKey 69 | false, // mandatory 70 | false, // immediate 71 | amqp.Publishing{ 72 | ContentType: "text/plain", 73 | Body: message, 74 | }) 75 | if err != nil { 76 | logrus.Errorf("failed to publish message, body: %v, reason: %s", string(message), err) 77 | return err 78 | } 79 | logrus.Infof("success to publish message to %s, body: %v", exchangeName, string(message)) 80 | return nil 81 | } 82 | 83 | func newClient(hash, exchangeName, exchangeType string) *connection { 84 | c, ok := connectionPool[hash] 85 | if !ok { 86 | if len(connectionPool) == 0 { 87 | connectionPool = make(map[string]*connection) 88 | } 89 | connectionPool[hash] = createConnection(exchangeName, exchangeType) 90 | c = connectionPool[hash] 91 | if err := c.Connect(); err != nil { 92 | logrus.Fatal(err) 93 | } 94 | } 95 | 96 | return c 97 | } 98 | 99 | func Publish(exchangeName, exchangeType, routingKey string, message []byte) error { 100 | hash := fmt.Sprintf("%s-%s-%s", exchangeName, exchangeType, routingKey) 101 | 102 | c := newClient(hash, exchangeName, exchangeType) 103 | 104 | if ch, ok := c.channels[hash]; ok { 105 | return publishAction(ch, exchangeName, routingKey, message) 106 | } else { 107 | channel, err := c.conn.Channel() 108 | if err != nil { 109 | return fmt.Errorf("channel: %s", err) 110 | } 111 | 112 | if !strings.HasPrefix(exchangeName, "amq.") { 113 | err = channel.ExchangeDeclare( 114 | exchangeName, 115 | exchangeType, 116 | true, 117 | false, 118 | false, 119 | false, 120 | nil, 121 | ) 122 | if err != nil { 123 | return fmt.Errorf("exchangeName Declare: %s", err) 124 | } 125 | } 126 | 127 | if reliable { 128 | // Reliable publisher confirms require confirm.select support from the connection. 129 | if err := channel.Confirm(false); err != nil { 130 | return fmt.Errorf("channel could not be put into confirm mode: %s", err) 131 | } 132 | 133 | confirms := channel.NotifyPublish(make(chan amqp.Confirmation, 1)) 134 | 135 | defer confirmOne(confirms) 136 | } 137 | 138 | c.channels[hash] = channel 139 | return publishAction(channel, exchangeName, routingKey, message) 140 | } 141 | } 142 | 143 | // Reconnect reconnects the connection 144 | func (c *connection) Reconnect() error { 145 | if err := c.Connect(); err != nil { 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | func (c *connection) Listen() { 152 | logrus.Infof("start listen channel: %s", c.exchange) 153 | for { 154 | if err := <-c.err; err != nil { 155 | err := c.Reconnect() 156 | if err != nil { 157 | logrus.Errorf("failed to reconnect mq, reason: %s", err) 158 | time.Sleep(5 * time.Second) 159 | } 160 | } 161 | } 162 | } 163 | 164 | func confirmOne(confirms <-chan amqp.Confirmation) { 165 | logrus.Infof("waiting for confirmation of one publishing") 166 | 167 | if confirmed := <-confirms; confirmed.Ack { 168 | logrus.Infof("confirmed delivery with delivery tag: %d", confirmed.DeliveryTag) 169 | } else { 170 | logrus.Infof("failed delivery of delivery tag: %d", confirmed.DeliveryTag) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /test/user_test.go: -------------------------------------------------------------------------------- 1 | // @Title user_test.go 2 | // @Description 3 | // @Author Hunter 2024/9/4 20:16 4 | 5 | package test 6 | 7 | import ( 8 | "fmt" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "go-gin-api-starter/internal/api" 15 | "go-gin-api-starter/internal/model" 16 | "go-gin-api-starter/pkg/util/response" 17 | "go-gin-api-starter/pkg/util/typeConversion" 18 | ) 19 | 20 | func TestUser(t *testing.T) { 21 | router := api.SetUpRouter() 22 | 23 | // Test login 24 | loginRequest := model.LoginRequest{ 25 | AccountName: "hunter", 26 | Password: "5d41402abc4b2a76b9719d911017c592", 27 | } 28 | 29 | w, req := createLoginRequest(t, loginRequest) 30 | 31 | router.ServeHTTP(w, req) 32 | 33 | assert.Equal(t, http.StatusOK, w.Code) 34 | 35 | var loginResp response.CustomResponse[model.AuthResponse] 36 | err := parseResponse(w.Body.String(), &loginResp) 37 | assert.NoError(t, err) 38 | 39 | assert.Equal(t, response.StatusOK, loginResp.Code) 40 | 41 | // Test refresh token 42 | refreshTokenRequest := model.RefreshTokenRequest{ 43 | RefreshToken: loginResp.Data.RefreshToken, 44 | } 45 | 46 | w, req = createRefreshTokenRequest(t, refreshTokenRequest) 47 | 48 | router.ServeHTTP(w, req) 49 | 50 | assert.Equal(t, http.StatusOK, w.Code) 51 | 52 | var newLoginResp response.CustomResponse[model.AuthResponse] 53 | 54 | err = parseResponse(w.Body.String(), &newLoginResp) 55 | assert.NoError(t, err) 56 | 57 | assert.Equal(t, response.StatusOK, newLoginResp.Code) 58 | 59 | // Test get user info 60 | userID := 1 61 | 62 | w, req = createGetUserInfoRequest(t, uint64(userID), newLoginResp.Data.AccessToken) 63 | 64 | router.ServeHTTP(w, req) 65 | 66 | assert.Equal(t, http.StatusOK, w.Code) 67 | 68 | var getUserInfoResp response.CustomResponse[model.User] 69 | 70 | err = parseResponse(w.Body.String(), &getUserInfoResp) 71 | 72 | assert.NoError(t, err) 73 | 74 | assert.Equal(t, response.StatusOK, getUserInfoResp.Code) 75 | 76 | // Test logout 77 | w, req = createLogoutRequest(t, newLoginResp.Data.RefreshToken) 78 | 79 | router.ServeHTTP(w, req) 80 | 81 | assert.Equal(t, http.StatusOK, w.Code) 82 | 83 | var logoutResp response.CustomResponse[string] 84 | 85 | err = parseResponse(w.Body.String(), &logoutResp) 86 | 87 | assert.NoError(t, err) 88 | 89 | assert.Equal(t, response.StatusOK, logoutResp.Code) 90 | 91 | // Test refresh token after logout 92 | w, req = createRefreshTokenRequest(t, refreshTokenRequest) 93 | 94 | router.ServeHTTP(w, req) 95 | 96 | assert.Equal(t, http.StatusOK, w.Code) 97 | 98 | err = parseResponse(w.Body.String(), &newLoginResp) 99 | 100 | assert.NoError(t, err) 101 | 102 | assert.Equal(t, response.StatusErr, newLoginResp.Code) 103 | 104 | // Test login with invalid password 105 | loginRequest = model.LoginRequest{ 106 | AccountName: "hunter", 107 | Password: "hello", 108 | } 109 | 110 | w, req = createLoginRequest(t, loginRequest) 111 | 112 | router.ServeHTTP(w, req) 113 | 114 | assert.Equal(t, http.StatusOK, w.Code) 115 | 116 | err = parseResponse(w.Body.String(), &loginResp) 117 | 118 | assert.NoError(t, err) 119 | 120 | assert.Equal(t, response.StatusErr, loginResp.Code) 121 | } 122 | 123 | func createLoginRequest(t *testing.T, loginRequest model.LoginRequest) (*httptest.ResponseRecorder, *http.Request) { 124 | loginRequestReader, err := typeConversion.StructToReader(loginRequest) 125 | assert.NoError(t, err) 126 | 127 | req, err := http.NewRequest(http.MethodPost, "/api/v1/user/auth", loginRequestReader) 128 | assert.NoError(t, err) 129 | 130 | return httptest.NewRecorder(), req 131 | } 132 | 133 | func createRefreshTokenRequest(t *testing.T, refreshTokenRequest model.RefreshTokenRequest) (*httptest.ResponseRecorder, *http.Request) { 134 | refreshTokenRequestReader, err := typeConversion.StructToReader(refreshTokenRequest) 135 | assert.NoError(t, err) 136 | 137 | req, err := http.NewRequest(http.MethodPut, "/api/v1/user/auth", refreshTokenRequestReader) 138 | assert.NoError(t, err) 139 | 140 | return httptest.NewRecorder(), req 141 | } 142 | 143 | func createLogoutRequest(t *testing.T, token string) (*httptest.ResponseRecorder, *http.Request) { 144 | req, err := http.NewRequest(http.MethodDelete, "/api/v1/user/auth", nil) 145 | assert.NoError(t, err) 146 | 147 | req.Header.Set("Authorization", "Bearer "+token) 148 | 149 | return httptest.NewRecorder(), req 150 | } 151 | 152 | func createGetUserInfoRequest(t *testing.T, userID uint64, token string) (*httptest.ResponseRecorder, *http.Request) { 153 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/user/%d", userID), nil) 154 | assert.NoError(t, err) 155 | 156 | req.Header.Set("Authorization", "Bearer "+token) 157 | 158 | return httptest.NewRecorder(), req 159 | } 160 | 161 | func parseResponse(respBody string, target interface{}) error { 162 | return typeConversion.StringToStruct(respBody, target) 163 | } 164 | -------------------------------------------------------------------------------- /pkg/util/formatValidator/user_info_validator_test.go: -------------------------------------------------------------------------------- 1 | // @Title user_info_validator_test.go 2 | // @Description 3 | // @Author Hunter 2024/9/3 17:36 4 | 5 | package formatValidator 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func TestCheckAccountName(t *testing.T) { 12 | type args struct { 13 | text string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | wantErr bool 19 | }{ 20 | { 21 | name: "case0", 22 | args: args{ 23 | text: "hello123", 24 | }, 25 | wantErr: false, 26 | }, 27 | { 28 | name: "case1", 29 | args: args{ 30 | text: "hello@123.world", 31 | }, 32 | wantErr: false, 33 | }, 34 | { 35 | name: "case2", 36 | args: args{ 37 | text: "hello@123.w*or&ld--=", 38 | }, 39 | wantErr: false, 40 | }, 41 | { 42 | name: "case3", 43 | args: args{ 44 | text: "hello@123.w*or&ld-你好-=", 45 | }, 46 | wantErr: true, 47 | }, 48 | { 49 | name: "case4", 50 | args: args{ 51 | text: "hello@123.w*or&ld-你好-=世界", 52 | }, 53 | wantErr: true, 54 | }, 55 | { 56 | name: "case4", 57 | args: args{ 58 | text: "x需求hello@123.w*or&ld-你好-=世界", 59 | }, 60 | wantErr: true, 61 | }, 62 | } 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | if err := ValidateAccountName(tt.args.text); (err != nil) != tt.wantErr { 66 | t.Errorf("CheckAccountName() error = %v, wantErr %v", err, tt.wantErr) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestCheckMobilePhoneNumber(t *testing.T) { 73 | type args struct { 74 | mobile string 75 | } 76 | tests := []struct { 77 | name string 78 | args args 79 | wantErr bool 80 | }{ 81 | { 82 | name: "case0", 83 | args: args{ 84 | mobile: "13218879988", 85 | }, 86 | wantErr: false, 87 | }, 88 | { 89 | name: "case0", 90 | args: args{ 91 | mobile: "1321887998", 92 | }, 93 | wantErr: true, 94 | }, 95 | { 96 | name: "case0", 97 | args: args{ 98 | mobile: "10218879989", 99 | }, 100 | wantErr: true, 101 | }, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | if err := ValidateMobileNumber(tt.args.mobile); (err != nil) != tt.wantErr { 106 | t.Errorf("CheckMobile() error = %v, wantErr %v", err, tt.wantErr) 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestCheckBlackIP(t *testing.T) { 113 | type args struct { 114 | text string 115 | } 116 | tests := []struct { 117 | name string 118 | args args 119 | wantErr bool 120 | }{ 121 | { 122 | name: "case0", 123 | args: args{ 124 | text: "192.168.31.10", 125 | }, 126 | wantErr: false, 127 | }, 128 | { 129 | name: "case1", 130 | args: args{ 131 | text: "193.168.31.*", 132 | }, 133 | wantErr: false, 134 | }, 135 | { 136 | name: "case2", 137 | args: args{ 138 | text: "192.168.269.*", 139 | }, 140 | wantErr: true, 141 | }, 142 | { 143 | name: "case3", 144 | args: args{ 145 | text: "192.168.*.*", 146 | }, 147 | wantErr: false, 148 | }, 149 | { 150 | name: "case4", 151 | args: args{ 152 | text: "*.168.*.*", 153 | }, 154 | wantErr: false, 155 | }, 156 | { 157 | name: "case5", 158 | args: args{ 159 | text: "*.*.*.*", 160 | }, 161 | wantErr: false, 162 | }, 163 | } 164 | for _, tt := range tests { 165 | t.Run(tt.name, func(t *testing.T) { 166 | if err := ValidateIPWithWildcard(tt.args.text); (err != nil) != tt.wantErr { 167 | t.Errorf("CheckBlackIP() error = %v, wantErr %v", err, tt.wantErr) 168 | } 169 | }) 170 | } 171 | } 172 | 173 | func TestCheckEmail(t *testing.T) { 174 | type args struct { 175 | email string 176 | } 177 | tests := []struct { 178 | name string 179 | args args 180 | wantErr bool 181 | }{ 182 | { 183 | name: "case0", 184 | args: args{ 185 | email: "hello@world.com", 186 | }, 187 | wantErr: false, 188 | }, 189 | { 190 | name: "case1", 191 | args: args{ 192 | email: "hello@world.com.cn", 193 | }, 194 | wantErr: false, 195 | }, 196 | { 197 | name: "case2", 198 | args: args{ 199 | email: "hello@worldcom", 200 | }, 201 | wantErr: true, 202 | }, 203 | { 204 | name: "case3", 205 | args: args{ 206 | email: "helloworld.com", 207 | }, 208 | wantErr: true, 209 | }, 210 | { 211 | name: "case4", 212 | args: args{ 213 | email: "你好@world.com", 214 | }, 215 | wantErr: false, 216 | }, 217 | { 218 | name: "case5", 219 | args: args{ 220 | email: "你好@world。com", 221 | }, 222 | wantErr: true, 223 | }, 224 | { 225 | name: "case6", 226 | args: args{ 227 | email: "你好@world.世界com", 228 | }, 229 | wantErr: true, 230 | }, 231 | { 232 | name: "case7", 233 | args: args{ 234 | email: "你好@world..com", 235 | }, 236 | wantErr: true, 237 | }, 238 | { 239 | name: "case8", 240 | args: args{ 241 | email: "你好@world\\.com", 242 | }, 243 | wantErr: true, 244 | }, 245 | } 246 | for _, tt := range tests { 247 | t.Run(tt.name, func(t *testing.T) { 248 | if err := ValidateEmail(tt.args.email); (err != nil) != tt.wantErr { 249 | t.Errorf("CheckEmail() error = %v, wantErr %v", err, tt.wantErr) 250 | } 251 | }) 252 | } 253 | } 254 | 255 | func TestCheckIP(t *testing.T) { 256 | type args struct { 257 | text string 258 | } 259 | tests := []struct { 260 | name string 261 | args args 262 | wantErr bool 263 | }{ 264 | { 265 | name: "case0", 266 | args: args{ 267 | text: "192.168.50.10", 268 | }, 269 | wantErr: false, 270 | }, 271 | { 272 | name: "case1", 273 | args: args{ 274 | text: "193.168.50.300", 275 | }, 276 | wantErr: true, 277 | }, 278 | { 279 | name: "case2", 280 | args: args{ 281 | text: "192.168.50", 282 | }, 283 | wantErr: true, 284 | }, 285 | { 286 | name: "case3", 287 | args: args{ 288 | text: "192.168.50.10.10", 289 | }, 290 | wantErr: true, 291 | }, 292 | { 293 | name: "case4", 294 | args: args{ 295 | text: "192.168", 296 | }, 297 | wantErr: true, 298 | }, 299 | { 300 | name: "case5", 301 | args: args{ 302 | text: "192", 303 | }, 304 | wantErr: true, 305 | }, 306 | { 307 | name: "case6", 308 | args: args{ 309 | text: "192.168.10.h", 310 | }, 311 | wantErr: true, 312 | }, 313 | } 314 | for _, tt := range tests { 315 | t.Run(tt.name, func(t *testing.T) { 316 | if err := ValidateIP(tt.args.text); (err != nil) != tt.wantErr { 317 | t.Errorf("CheckIP() error = %v, wantErr %v", err, tt.wantErr) 318 | } 319 | }) 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /doc/README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # go-gin-api-starter 2 | 3 | [English](https://github.com/hunter-ji/go-gin-api-starter) | 简体中文 4 | 5 | ## 📚 项目介绍 6 | 7 | go-gin-api-starter是一个基于Gin框架的RESTful API项目模板,旨在帮助开发者快速构建和启动高效、可扩展的Go后端服务。这个模板采用了MVC架构的变体,专注于API开发,提供了一个结构清晰、易于扩展的项目基础。 8 | 9 | 如果你有兴趣,也可以看看我的其它模板: 10 | 11 | - 前端模板:[vue-ts-tailwind-vite-starter](https://github.com/hunter-ji/vue-ts-tailwind-vite-starter) 12 | - 数据库模板:[postgres-redis-dev-docker-compose](https://github.com/hunter-ji/postgres-redis-dev-docker-compose) 13 | 14 | ## ✨ 项目特性 15 | 16 | - **Gin 框架**: 利用Gin的高性能和灵活性,快速构建RESTful API。 17 | - **MVC 架构**: 采用MVC的设计理念,实现关注点分离,提高代码的可维护性和可测试性。 18 | - **模块化结构**: 清晰的目录结构,便于项目的扩展和维护。 19 | - **中间件支持**: 包含常用中间件,如日志记录、错误处理和认证等。 20 | - **配置管理**: 灵活的配置管理,支持多环境部署。 21 | - **自定义验证器**: 内置自定义的格式化验证器,已注册到Gin的binding中,可灵活扩展。 22 | - **JWT 认证**: 封装了JWT认证机制,包括token自动刷新和主动请求刷新功能,可直接使用。 23 | - **开发模式日志增强**: 在开发模式下,自动打印详细的HTTP请求和响应信息,极大方便了API的调试和开发过程。 24 | - **PostgreSQL 集成**: 使用PostgreSQL作为主数据库,确保数据的可靠性和强大的查询能力。 25 | - **Redis 支持**: 集成Redis用于缓存和会话管理,提升应用性能。 26 | - **用户模块**: 预置用户模块及其测试代码,为快速开发提供参考和基础。 27 | - **API 版本控制**: 内置API版本控制机制,便于管理不同版本的API。 28 | 29 | ## 🚀 快速开始 30 | 31 | 按照以下步骤快速设置和运行您的项目: 32 | 33 | ### 1. 克隆项目 34 | 35 | ```bash 36 | git clone https://github.com/hunter-ji/go-gin-api-starter 37 | cd go-gin-api-starter 38 | ``` 39 | 40 | ### 2. 初始化项目 41 | 42 | 使用提供的 `project_bootstrap.sh` 脚本来快速初始化您的项目: 43 | 44 | ```bash 45 | bash project_bootstrap.sh 46 | ``` 47 | 48 | 过程如下: 49 | 50 | ``` 51 | 🚀 Welcome to the Go Project Initializer! 52 | 53 | 📝 Please enter the new project name: 54 | > new-project 55 | 56 | Initializing your project... 57 | Project name updated successfully! 58 | 59 | 🐰 Do you need RabbitMQ in your project? [Y/n]: 60 | > n 61 | RabbitMQ folder removed. 62 | 63 | 🗑️ Do you want to remove the existing .git directory? [Y/n]: 64 | > 65 | Existing .git directory removed. 66 | 67 | 🔧 Do you want to initialize a new Git repository? [Y/n]: 68 | > 69 | Initialized empty Git repository in /path/to/new-project/.git/ 70 | New Git repository initialized successfully. 71 | 72 | 🎉 Project initialization complete! 73 | Summary: 74 | Old project name: Template 75 | New project name: new-project 76 | RabbitMQ: Removed 77 | Git: Reinitialized 78 | 79 | 🧹 Do you want to run 'go mod tidy' to clean up dependencies? [Y/n]: 80 | > n 81 | Skipped 'go mod tidy'. Remember to run it later if needed. 82 | 83 | 🚀 How to start your project: 84 | 1. Modify .env.development in the project root to set Redis and PostgreSQL configurations. 85 | 2. Run the following command to start your project: 86 | make run 87 | 88 | 🎈 Your project is ready! Happy coding! 89 | ``` 90 | 91 | 这将会帮助你完成以下操作: 92 | 93 | - 更换项目名称 94 | - 删除不需要的模块,比如`RabbitMQ` 95 | - 删除/重新初始化`.git`文件夹 96 | - 安装依赖 97 | 98 | ### 3. 配置数据库 99 | 100 | 在 `.env.development` 文件中添加 Redis 和 PostgreSQL 的配置。示例配置如下: 101 | 102 | ``` 103 | # postgresql 104 | DB_HOST= 105 | DB_PORT= 106 | DB_USER= 107 | DB_PASSWORD= 108 | DB_DATABASE_NAME= 109 | 110 | # redis 111 | REDIS_HOST= 112 | REDIS_PORT= 113 | REDIS_PASSWORD= 114 | REDIS_DB=0 115 | ``` 116 | 117 | 若需要快速启动开发环境的数据库,可以使用我的 [Database Docker Compose](https://github.com/hunter-ji/postgres-redis-dev-docker-compose) 。 118 | 119 | ### 4. 启动项目 120 | 121 | 配置完成后,使用以下命令启动项目: 122 | 123 | ```bash 124 | make run 125 | ``` 126 | 127 | ### 5. 验证 128 | 129 | 打开浏览器或使用 curl 命令访问: 130 | 131 | ``` 132 | http://localhost:9000/api/ping 133 | ``` 134 | 135 | 如果一切正常,应该看到 "pong" 响应。 136 | 137 | ## 🏗️ 项目结构 138 | 139 | ### `/cmd` 140 | 141 | 项目的主要应用程序。 142 | 143 | - `/api`: API 服务器的入口点。 144 | 145 | ### `/config` 146 | 147 | 配置文件和配置加载逻辑。 148 | 149 | - `config.go`: 主配置文件。 150 | - `constant.go`: 常量定义。 151 | - `environment_variable.go`: 环境变量处理。 152 | - `router_white_list.go`: 路由白名单配置。 153 | 154 | ### `/doc` 155 | 156 | 设计文档、用户文档和其他项目相关文档。 157 | 158 | ### `/internal` 159 | 160 | 私有应用程序和库代码。 161 | 162 | - `/api`: API 层,定义路由和处理函数。 163 | - `/constant`: 内部使用的常量定义。 164 | - `/database`: 数据库连接和初始化逻辑。 165 | - `/middleware`: HTTP 中间件。 166 | - `/model`: 数据模型和结构体定义。 167 | - `/repository`: 数据访问层,处理数据持久化。 168 | - `/service`: 业务逻辑层。 169 | 170 | ### `/migration` 171 | 172 | 数据库迁移文件。 173 | 174 | - `000001_init_schema.down.sql`: 初始化 schema 的回滚脚本。 175 | - `000001_init_schema.up.sql`: 初始化 schema 的执行脚本。 176 | 177 | ### `/pkg` 178 | 179 | 可以被外部应用程序使用的库代码。 180 | 181 | - `/auth`: 认证相关功能。 182 | - `/util`: 通用工具函数。 183 | 184 | ### `/test` 185 | 186 | 额外的外部测试应用程序和测试数据。 187 | 188 | - `ping_test.go`: Ping 功能测试。 189 | - `user_test.go`: 用户相关功能测试。 190 | 191 | ### 根目录文件 192 | 193 | - `Makefile`: 定义常用命令的 Makefile。 194 | - `project_bootstrap.sh`: 项目初始化脚本。 195 | 196 | ## 🧪 运行测试 197 | 198 | 本项目提供了多种运行测试的方式,以满足不同的测试需求。 199 | 200 | ### 运行所有测试 201 | 202 | 运行项目中的所有测试: 203 | 204 | ```bash 205 | make test 206 | ``` 207 | 208 | ### 运行特定文件夹中的测试 209 | 210 | 运行 ./test 文件夹中的测试: 211 | 212 | ```bash 213 | make test-folder 214 | ``` 215 | 216 | ### 运行特定测试文件 217 | 218 | 运行指定的测试文件: 219 | 220 | ```bash 221 | make test-file FILE=path/to/your_test.go 222 | ``` 223 | 224 | 例如: 225 | 226 | ```bash 227 | make test-file FILE=./test/user_test.go 228 | ``` 229 | 230 | ### 输出详细的测试日志 231 | 232 | 对于如上测试指令,都可加上`-verbose`参数,例如: 233 | 234 | ```bash 235 | make test-verbose 236 | make test-folder-verbose 237 | make test-file-verbose FILE=./test/user_test.go 238 | ``` 239 | 240 | ## 🔄 切换运行环境 241 | 242 | 项目依赖于环境变量 `NODE_ENV` 来切换环境,不设置默认为 `development`。 243 | 项目会根据不同的环境加载不同的配置文件,如 `.env.development`、`.env.test`、`.env.production` 等。 244 | 只需创建一个新的 `.env` 文件,然后设置 `NODE_ENV` 变量即可。 245 | 246 | ## 📖 更多使用方法 247 | 248 | 更多使用方法可以参考: 249 | 250 | - `Makefile`文件,里面定义了常用的命令,如运行、测试、构建、清理等。 251 | - 模块文件夹内的README文件 252 | - 代码注释 253 | 254 | 后续将会持续更新和完善文档。 255 | 256 | ## ❓ 常见问题 (FAQ) 257 | 258 | ### Q1: 如何更改默认的服务器端口? 259 | 260 | A: 在 `.env.development` 文件中修改 `SERVER_PORT` 变量。例如,将其设置为 `SERVER_PORT=8080` 会使服务器在 8080 端口上运行。 261 | 262 | ### Q2: 项目支持哪些数据库? 263 | 264 | A: 目前项目主要支持 PostgreSQL 作为主数据库。如果您需要使用其他数据库,可能需要修改 `internal/database` 中的数据库连接代码。 265 | 266 | ### Q3: 如何添加新的 API 路由? 267 | 268 | A: 在 `internal/api` 目录下相应的版本文件夹中(如 `v1`)添加新的处理函数,然后在 `internal/api/router.go` 文件中注册这个新路由。 269 | 270 | ### Q4: 如何自定义中间件? 271 | 272 | A: 在 `internal/middleware` 目录下创建新的中间件文件,实现中间件逻辑,然后在 `internal/api/router.go` 中使用 `r.Use()` 273 | 方法应用这个中间件。 274 | 275 | ### Q5: 开发模式下的日志增强功能如何开启或关闭? 276 | 277 | A: 这个功能默认在开发模式下自动开启。如果您想在生产环境中禁用它,请确保环境变量 `NODE_ENV` 不为 `development`。 278 | 279 | ### Q6: 如何运行特定的测试? 280 | 281 | A: 使用 `make test-file` 命令,指定要运行的测试文件。例如: 282 | 283 | ```bash 284 | make test-file FILE=./test/user_test.go 285 | ``` 286 | 287 | ### Q7: JWT token 的过期时间如何配置? 288 | 289 | A: 在 `pkg/auth/token.go` 文件中修改 `accessTokenExpire` 等变量。 290 | 291 | ### Q8: 如何添加新的自定义验证器? 292 | 293 | A: 在 `pkg/util/formatValidator` 目录下添加新的验证函数,然后在 `pkg/util/customBindValidator` 中注册这个新的验证器。 294 | 295 | ### Q9: 项目是否支持 CORS? 296 | 297 | A: 是的,项目默认配置了 CORS 中间件。 298 | 299 | ## 💬 作者留言 300 | 301 | 前些年写Go项目的时候,自己总结了一个模板,最近开发新的项目,发现了一些不足,以及之前还有些不够完善的地方,一直没有系统地去更新和完善。 302 | 这次就直接从头开始,重新整理了一下,不仅便利了自己,还希望也能有机会帮到别的开发者。 303 | 304 | 这个项目会带有我的一些个人风格,比如目录结构、命名规范等,当然,这些都是可以根据自己的喜好和项目需求来调整的。如有建议,欢迎提Issue或PR。 305 | -------------------------------------------------------------------------------- /pkg/auth/token.go: -------------------------------------------------------------------------------- 1 | // @Title token.go 2 | // @Description Use jwt as token, provide access token and refresh token generation. 3 | // And encapsulate cache storage for refresh token, directly call to generate token and verify. 4 | // Everything will be simple. 5 | // @Author Hunter 2024/9/4 16:48 6 | 7 | package auth 8 | 9 | import ( 10 | "context" 11 | "errors" 12 | "fmt" 13 | "sync" 14 | "time" 15 | 16 | "github.com/go-redis/redis/v8" 17 | "github.com/golang-jwt/jwt/v5" 18 | "go-gin-api-starter/config" 19 | ) 20 | 21 | var ( 22 | accessTokenSecret []byte 23 | refreshTokenSecret []byte 24 | 25 | refreshMutexes sync.Map 26 | tokenCache sync.Map 27 | 28 | accessTokenExpire = 10 * 24 * time.Hour 29 | refreshTokenExpire = 15 * 24 * time.Hour 30 | ) 31 | 32 | type cachedToken struct { 33 | accessToken string 34 | refreshToken string 35 | expiry time.Time 36 | } 37 | 38 | type Claims struct { 39 | ContextUserInfo 40 | jwt.RegisteredClaims 41 | } 42 | 43 | // ContextUserInfo 44 | // @Description: user info in claims and context, update it when needed 45 | type ContextUserInfo struct { 46 | UserID uint64 `json:"userID"` 47 | } 48 | 49 | func init() { 50 | accessTokenSecret = []byte(config.TokenConfig.AccessTokenSecret) 51 | refreshTokenSecret = []byte(config.TokenConfig.RefreshTokenSecret) 52 | } 53 | 54 | // GenerateAccessToken 55 | // @Description: Generate new access token 56 | // @param userID uint64 57 | // @return string access token 58 | // @return error 59 | func GenerateAccessToken(userinfo ContextUserInfo) (string, error) { 60 | expirationTime := time.Now().Add(accessTokenExpire) 61 | claims := &Claims{ 62 | ContextUserInfo: userinfo, 63 | RegisteredClaims: jwt.RegisteredClaims{ 64 | ExpiresAt: jwt.NewNumericDate(expirationTime), 65 | IssuedAt: jwt.NewNumericDate(time.Now()), 66 | NotBefore: jwt.NewNumericDate(time.Now()), 67 | }, 68 | } 69 | 70 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 71 | return token.SignedString(accessTokenSecret) 72 | } 73 | 74 | func generateRefreshToken(userinfo ContextUserInfo) (string, error) { 75 | expirationTime := time.Now().Add(refreshTokenExpire) 76 | claims := &Claims{ 77 | ContextUserInfo: userinfo, 78 | RegisteredClaims: jwt.RegisteredClaims{ 79 | ExpiresAt: jwt.NewNumericDate(expirationTime), 80 | IssuedAt: jwt.NewNumericDate(time.Now()), 81 | NotBefore: jwt.NewNumericDate(time.Now()), 82 | }, 83 | } 84 | 85 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 86 | return token.SignedString(refreshTokenSecret) 87 | } 88 | 89 | func ValidateAccessToken(tokenString string) (*Claims, bool, error) { 90 | return validateToken(tokenString, accessTokenSecret) 91 | } 92 | 93 | func validateRefreshToken(tokenString string) (*Claims, bool, error) { 94 | return validateToken(tokenString, refreshTokenSecret) 95 | } 96 | 97 | // validateToken 98 | // @Description: validate token 99 | // @param tokenString token string 100 | // @param secret secret key 101 | // @return *Claims 102 | // @return bool expired 103 | // @return error 104 | func validateToken(tokenString string, secret []byte) (*Claims, bool, error) { 105 | token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { 106 | return secret, nil 107 | }) 108 | 109 | if err != nil { 110 | if errors.Is(err, jwt.ErrTokenExpired) { 111 | return nil, true, err 112 | } 113 | return nil, false, err 114 | } 115 | 116 | if claims, ok := token.Claims.(*Claims); ok && token.Valid { 117 | return claims, false, nil 118 | } 119 | 120 | return nil, false, jwt.ErrSignatureInvalid 121 | } 122 | 123 | func generateRefreshTokenStoreKey(userID uint64) string { 124 | return fmt.Sprintf("%s-refresh-token:%d", config.CommonSplicePrefix, userID) 125 | } 126 | 127 | func storeRefreshToken(userID uint64, refreshToken string, redisClient *redis.Client) error { 128 | key := generateRefreshTokenStoreKey(userID) 129 | return redisClient.Set(context.Background(), key, refreshToken, refreshTokenExpire).Err() 130 | } 131 | 132 | func validateStoredRefreshToken(userID uint64, refreshToken string, redisClient *redis.Client) bool { 133 | key := generateRefreshTokenStoreKey(userID) 134 | storedToken, err := redisClient.Get(context.Background(), key).Result() 135 | if err != nil { 136 | return false 137 | } 138 | return storedToken == refreshToken 139 | } 140 | 141 | func deleteRefreshToken(userID uint64, redisClient *redis.Client) error { 142 | key := generateRefreshTokenStoreKey(userID) 143 | redisClient.Del(context.Background(), key) 144 | return redisClient.Del(context.Background(), key).Err() 145 | } 146 | 147 | // GenerateAccessTokenAndRefreshToken 148 | // @Description: Generate new access token and refresh token, then store the refresh token 149 | // @param userID uint64 150 | // @param redisClient *redis.Client 151 | // @return string access token 152 | // @return string refresh token 153 | // @return error 154 | func GenerateAccessTokenAndRefreshToken(userinfo ContextUserInfo, redisClient *redis.Client) (string, string, error) { 155 | // Generate new access token 156 | newAccessToken, err := GenerateAccessToken(userinfo) 157 | if err != nil { 158 | return "", "", fmt.Errorf("failed to generate new access token: %w", err) 159 | } 160 | 161 | // Generate new refresh token 162 | newRefreshToken, err := generateRefreshToken(userinfo) 163 | if err != nil { 164 | return "", "", fmt.Errorf("failed to generate new refresh token: %w", err) 165 | } 166 | 167 | // Store new refresh token 168 | if err := storeRefreshToken(userinfo.UserID, newRefreshToken, redisClient); err != nil { 169 | return "", "", fmt.Errorf("failed to store new refresh token: %w", err) 170 | } 171 | 172 | return newAccessToken, newRefreshToken, nil 173 | } 174 | 175 | // ValidateAccessTokenAndRefresh 176 | // @Description: validate access token and refresh it if expired 177 | // @param accessToken 178 | // @param redisClient 179 | // @return *Claims 180 | // @return string new access token 181 | // @return bool expired 182 | // @return error 183 | func ValidateAccessTokenAndRefresh(accessToken string, redisClient *redis.Client) (*Claims, string, bool, error) { 184 | // Validate access token 185 | claims, expired, err := ValidateAccessToken(accessToken) 186 | if err != nil { 187 | return nil, "", false, fmt.Errorf("invalid access token: %w", err) 188 | } 189 | 190 | // Return empty string if access token is not expired 191 | if !expired { 192 | return claims, "", false, nil 193 | } 194 | 195 | userID := claims.UserID 196 | 197 | // Check cache first 198 | if cachedToken, ok := getTokenFromCache(userID); ok { 199 | return claims, cachedToken.accessToken, false, nil 200 | } 201 | 202 | // Get or create a mutex for this userID 203 | mutex, _ := refreshMutexes.LoadOrStore(userID, &sync.Mutex{}) 204 | mtx := mutex.(*sync.Mutex) 205 | mtx.Lock() 206 | defer mtx.Unlock() 207 | 208 | // Check cache again after acquiring the lock 209 | if cachedToken, ok := getTokenFromCache(userID); ok { 210 | return claims, cachedToken.accessToken, false, nil 211 | } 212 | 213 | // Get refresh token from redis 214 | key := fmt.Sprintf("%s-refresh-token:%d", config.CommonSplicePrefix, userID) 215 | refreshToken, err := redisClient.Get(context.Background(), key).Result() 216 | if err != nil { 217 | return nil, "", true, fmt.Errorf("failed to get refresh token: %w", err) 218 | } 219 | 220 | // Validate refresh token 221 | if _, _, err := validateRefreshToken(refreshToken); err != nil { 222 | return nil, "", true, fmt.Errorf("invalid refresh token: %w", err) 223 | } 224 | 225 | // Generate new tokens 226 | newAccessToken, _, err := GenerateAccessTokenAndRefreshToken(claims.ContextUserInfo, redisClient) 227 | if err != nil { 228 | return nil, "", false, err 229 | } 230 | 231 | return claims, newAccessToken, false, nil 232 | } 233 | 234 | // RefreshToken 235 | // @Description: Refresh access token and refresh token 236 | // @param refreshToken string 237 | // @param redisClient *redis.Client 238 | // @return string access token 239 | // @return string refresh token 240 | // @return error 241 | func RefreshToken(refreshToken string, redisClient *redis.Client) (string, string, error) { 242 | // Validate refresh token 243 | claims, expired, err := validateRefreshToken(refreshToken) 244 | if err != nil { 245 | if expired { 246 | return "", "", errors.New("refresh token expired") 247 | } 248 | return "", "", fmt.Errorf("invalid refresh token: %w", err) 249 | } 250 | 251 | userID := claims.UserID 252 | 253 | // Validate stored refresh token 254 | if !validateStoredRefreshToken(userID, refreshToken, redisClient) { 255 | return "", "", errors.New("invalid refresh token") 256 | } 257 | 258 | // Check cache first 259 | if cachedToken, ok := getTokenFromCache(userID); ok { 260 | return cachedToken.accessToken, cachedToken.refreshToken, nil 261 | } 262 | 263 | // Get or create a mutex for this userID 264 | mutex, _ := refreshMutexes.LoadOrStore(userID, &sync.Mutex{}) 265 | mtx := mutex.(*sync.Mutex) 266 | mtx.Lock() 267 | defer mtx.Unlock() 268 | 269 | // Check cache again after acquiring the lock 270 | if cachedToken, ok := getTokenFromCache(userID); ok { 271 | return cachedToken.accessToken, cachedToken.refreshToken, nil 272 | } 273 | 274 | // Generate new tokens 275 | newAccessToken, newRefreshToken, err := GenerateAccessTokenAndRefreshToken(claims.ContextUserInfo, redisClient) 276 | if err != nil { 277 | return "", "", err 278 | } 279 | 280 | // Cache the new tokens 281 | cacheToken(userID, newAccessToken, newRefreshToken) 282 | 283 | return newAccessToken, newRefreshToken, nil 284 | } 285 | 286 | // DeleteToken 287 | // @Description: Delete token 288 | // @param userID 289 | // @param redisClient 290 | // @return error 291 | func DeleteToken(userID uint64, redisClient *redis.Client) error { 292 | return deleteRefreshToken(userID, redisClient) 293 | } 294 | 295 | func getTokenFromCache(userID uint64) (cachedToken, bool) { 296 | if tokenInterface, ok := tokenCache.Load(userID); ok { 297 | token := tokenInterface.(cachedToken) 298 | if time.Now().Before(token.expiry) { 299 | return token, true 300 | } 301 | // Expired cache, remove it 302 | tokenCache.Delete(userID) 303 | } 304 | return cachedToken{}, false 305 | } 306 | 307 | func cacheToken(userID uint64, accessToken, refreshToken string) { 308 | tokenCache.Store(userID, cachedToken{ 309 | accessToken: accessToken, 310 | refreshToken: refreshToken, 311 | expiry: time.Now().Add(5 * time.Minute), // cache for 5 minutes 312 | }) 313 | } 314 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-gin-api-starter 2 | 3 | English | [简体中文](https://github.com/hunter-ji/go-gin-api-starter/blob/main/doc/README.zh-CN.md) 4 | 5 | ## 📚 Project Introduction 6 | 7 | go-gin-api-starter is a RESTful API project starter based on the Gin framework. It helps developers quickly build and 8 | start scalable Go backend services. This starter template focusing on API development. It provides a clear and easy project base. 9 | 10 | You can also check out my other templates: 11 | 12 | - Frontend template: [vue-ts-tailwind-vite-starter](https://github.com/hunter-ji/vue-ts-tailwind-vite-starter) 13 | - Database template: [postgres-redis-dev-docker-compose](https://github.com/hunter-ji/postgres-redis-dev-docker-compose) 14 | 15 | ## ✨ Project Features 16 | 17 | - **Gin Framework**: Uses Gin's high performance and flexibility to quickly build RESTful APIs. 18 | - **MVC**: Uses MVC design to make code easier to maintain and test. 19 | - **PostgreSQL Support**: Uses PostgreSQL as the main database for reliable data and strong query abilities. 20 | - **Redis Support**: Uses Redis for caching management to improve app performance. 21 | - **Modular Structure**: Clear directory structure for easy project expansion and maintenance. 22 | - **Middleware Support**: Includes common middlewares like logging, error handling, and authentication. 23 | - **Config Management**: Flexible config management, supports multi-environment deployment. 24 | - **Custom Validators**: Built-in custom format validators, registered in Gin's binding for easy expansion. 25 | - **JWT Authentication**: Including auto token refresh and active refresh requests, easy to use. 26 | - **User Module**: Pre-set user module and test code for quick development reference and base. 27 | - **API Version Control**: Built-in API version control for managing different API versions. 28 | - **Enhanced Dev Mode Logging**: In dev mode, automatically prints detailed HTTP request and response info, greatly 29 | helping API debugging and development. 30 | 31 | ## 🚀 Quick Start 32 | 33 | Follow these steps to quickly set up and run your project: 34 | 35 | ### 1. Clone the Project 36 | 37 | ```bash 38 | git clone https://github.com/hunter-ji/go-gin-api-starter 39 | cd go-gin-api-starter 40 | ``` 41 | 42 | ### 2. Initialize Project 43 | 44 | Use the provided `project_bootstrap.sh` script to quickly initialize your project: 45 | 46 | ```bash 47 | bash project_bootstrap.sh 48 | ``` 49 | 50 | The process is as follows: 51 | 52 | ``` 53 | 🚀 Welcome to the Go Project Initializer! 54 | 55 | 📝 Please enter the new project name: 56 | > new-project 57 | 58 | Initializing your project... 59 | Project name updated successfully! 60 | 61 | 🐰 Do you need RabbitMQ in your project? [Y/n]: 62 | > n 63 | RabbitMQ folder removed. 64 | 65 | 🗑️ Do you want to remove the existing .git directory? [Y/n]: 66 | > 67 | Existing .git directory removed. 68 | 69 | 🔧 Do you want to initialize a new Git repository? [Y/n]: 70 | > 71 | Initialized empty Git repository in /path/to/new-project/.git/ 72 | New Git repository initialized successfully. 73 | 74 | 🎉 Project initialization complete! 75 | Summary: 76 | Old project name: Template 77 | New project name: new-project 78 | RabbitMQ: Removed 79 | Git: Reinitialized 80 | 81 | 🧹 Do you want to run 'go mod tidy' to clean up dependencies? [Y/n]: 82 | > n 83 | Skipped 'go mod tidy'. Remember to run it later if needed. 84 | 85 | 🚀 How to start your project: 86 | 1. Modify .env.development in the project root to set Redis and PostgreSQL configurations. 87 | 2. Run the following command to start your project: 88 | make run 89 | 90 | 🎈 Your project is ready! Happy coding! 91 | ``` 92 | 93 | This will help you: 94 | 95 | - Change project name 96 | - Remove unneeded modules, like RabbitMQ 97 | - Delete/reinitialize .git folder 98 | - Install dependencies 99 | 100 | ### 3. Configure Database 101 | 102 | Add Redis and PostgreSQL configs in the .env.development file. Example config: 103 | 104 | ``` 105 | # postgresql 106 | DB_HOST= 107 | DB_PORT= 108 | DB_USER= 109 | DB_PASSWORD= 110 | DB_DATABASE_NAME= 111 | 112 | # redis 113 | REDIS_HOST= 114 | REDIS_PORT= 115 | REDIS_PASSWORD= 116 | REDIS_DB=0 117 | ``` 118 | 119 | For a quick dev environment database, you can use 120 | my [Database Docker Compose](https://github.com/hunter-ji/postgres-redis-dev-docker-compose). 121 | 122 | ### 4. Start Project 123 | 124 | After config, use this command to start the project: 125 | 126 | ```bash 127 | make run 128 | ``` 129 | 130 | ### 5. Verify 131 | 132 | Open a browser or use curl to access: 133 | 134 | ``` 135 | http://localhost:9000/api/ping 136 | ``` 137 | 138 | If all is well, you should see a "pong" response. 139 | 140 | ## 🏗️ Project Structure 141 | 142 | ### `/cmd` 143 | 144 | Main project applications. 145 | 146 | - `/api`: Entry point for API server. 147 | 148 | ### `/config` 149 | 150 | Config files and loading logic. 151 | 152 | - config.go: Main config file. 153 | - constant.go: Constant definitions. 154 | - environment_variable.go: Environment variable handling. 155 | - router_white_list.go: Router whitelist config. 156 | 157 | ### `/doc` 158 | 159 | Design docs, user docs, and other project-related docs. 160 | 161 | ### `/internal` 162 | 163 | Private app and library code. 164 | 165 | - `/api`: API layer, defines routes and handler functions. 166 | - `/constant`: Internal constants. 167 | - `/database`: Database connection and init logic. 168 | - `/middleware`: HTTP middleware. 169 | - `/model`: Data models and struct definitions. 170 | - `/repository`: Data access layer, handles data persistence. 171 | - `/service`: Business logic layer. 172 | 173 | ### `/migration` 174 | 175 | Database migration files. 176 | 177 | - `000001_init_schema.down.sql`: Rollback script for initial schema. 178 | - `000001_init_schema.up.sql`: Execution script for initial schema. 179 | 180 | ### `/pkg` 181 | 182 | Library code that can be used by external apps. 183 | 184 | - /auth: Auth-related functions. 185 | - /util: Common utility functions. 186 | 187 | ### `/test` 188 | 189 | Additional external test apps and test data. 190 | 191 | - `ping_test.go`: Ping function test. 192 | - `user_test.go`: User-related function tests. 193 | 194 | ### Root Directory Files 195 | 196 | - `Makefile`: Makefile defining common commands. 197 | - `project_bootstrap.sh`: Project init script. 198 | 199 | ## 🧪 Running Tests 200 | 201 | This project offers various ways to run tests for different testing needs. 202 | 203 | ### Run All Tests 204 | 205 | Run all tests in the project: 206 | 207 | ```bash 208 | make test 209 | ``` 210 | 211 | ### Run Tests in a Specific Folder 212 | 213 | Run tests in the ./test folder: 214 | 215 | ```bash 216 | make test-folder 217 | ``` 218 | 219 | ### Run a Specific Test File 220 | 221 | Run a specified test file: 222 | 223 | ```bash 224 | make test-file FILE=path/to/your_test.go 225 | ``` 226 | 227 | For example: 228 | 229 | ```bash 230 | make test-file FILE=./test/user_test.go 231 | ``` 232 | 233 | ### Output Detailed Test Logs 234 | 235 | For all the above test commands, you can add -verbose, for example: 236 | 237 | ```bash 238 | make test-verbose 239 | make test-folder-verbose 240 | make test-file-verbose FILE=./test/user_test.go 241 | ``` 242 | 243 | ## 🔄 Switching Run Environments 244 | 245 | The project relies on the `NODE_ENV` environment variable to switch environments. If not set, it defaults to 246 | `development`. 247 | The project loads different config files based on the environment, like `.env.development`, `.env.test`, 248 | `.env.production`, etc. 249 | Just create a new `.env` file and set the `NODE_ENV` variable. 250 | 251 | ## 📖 More Usage Methods 252 | 253 | For more usage methods, you can refer to: 254 | 255 | - The `Makefile` file, which defines common commands like run, test, build, clean, etc. 256 | - README files inside module folders 257 | - Code comments 258 | 259 | The documentation will be continuously updated and improved in the future. 260 | 261 | ## ❓ Frequently Asked Questions (FAQ) 262 | 263 | ### Q1: How to change the default server port? 264 | 265 | A: Modify the `SERVER_PORT` variable in the `.env.development` file. For example, setting it to `SERVER_PORT=8080` will 266 | make 267 | the server run on port 8080. 268 | 269 | ### Q2: Which databases does the project support? 270 | 271 | A: Currently, the project mainly supports PostgreSQL as the main database. If you need to use other databases, you may 272 | need to modify the database connection code in `internal/database`. 273 | 274 | ### Q3: How to add new API routes? 275 | 276 | A: Add new handler functions in the appropriate version folder (like v1) under the `internal/api` directory, then 277 | register 278 | this new route in the `internal/api/router.go` file. 279 | 280 | ### Q4: How to customize middleware? 281 | 282 | A: Create a new middleware file in the `internal/middleware` directory, implement the middleware logic, then use the 283 | `r.Use()` method in `internal/api/router.go` to apply this middleware. 284 | 285 | ### Q5: How to turn on or off the enhanced logging feature in development mode? 286 | 287 | A: This feature is automatically enabled in development mode by default. If you want to disable it in production, make 288 | sure the `NODE_ENV` environment variable is not set to `development`. 289 | 290 | ### Q6: How to run specific tests? 291 | 292 | A: Use the make test-file command, specifying the test file to run. For example: 293 | 294 | ```bash 295 | make test-file FILE=./test/user_test.go 296 | ``` 297 | 298 | ### Q7: How to configure JWT token expiration time? 299 | 300 | A: Modify variables like `accessTokenExpire` in the `pkg/auth/token.go` file. 301 | 302 | ### Q8: How to add new custom validators? 303 | 304 | A: Add new validation functions in the `pkg/util/formatValidator` directory, then register this new validator in 305 | `pkg/util/customBindValidator`. 306 | 307 | ### Q9: Does the project support CORS? 308 | 309 | A: Yes, the project has CORS middleware configured by default. 310 | 311 | ## 💬 Author's Note 312 | 313 | In recent years when writing Go projects, I summarized a template for myself. When developing new projects recently, I 314 | found some shortcomings and areas that were not fully improved before, which I hadn't systematically updated and 315 | improved. This time, before developing the project, I started from scratch and reorganized it. Not only did it benefit 316 | myself, but I also hope it can help other developers. 317 | 318 | This project will have some of my personal style, such as directory structure, naming conventions, etc. Of course, these 319 | can be adjusted according to your own preferences and project requirements. 320 | 321 | If you have any suggestions, you are welcome to issue or PR. 322 | 323 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= 2 | github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= 3 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 4 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 5 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 6 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 7 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 8 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 10 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 11 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 12 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 20 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 21 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 22 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 23 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 24 | github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= 25 | github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= 26 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 27 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 28 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 29 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 30 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 31 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 32 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 33 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 34 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 35 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 36 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 37 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 38 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 39 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 40 | github.com/go-redis/redis_rate/v9 v9.1.2 h1:H0l5VzoAtOE6ydd38j8MCq3ABlGLnvvbA1xDSVVCHgQ= 41 | github.com/go-redis/redis_rate/v9 v9.1.2/go.mod h1:oam2de2apSgRG8aJzwJddXbNu91Iyz1m8IKJE2vpvlQ= 42 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 43 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 44 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 45 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 46 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 47 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 49 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 50 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 51 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 52 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 53 | github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= 54 | github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 55 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 56 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 57 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 58 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 59 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 60 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 61 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 62 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 63 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 64 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 65 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 66 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 67 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 68 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 69 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 70 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 71 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 72 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 73 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 74 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 75 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 76 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 77 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 78 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 79 | github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA= 80 | github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 81 | github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk= 82 | github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= 83 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 84 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 86 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 87 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 88 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 89 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 90 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 91 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 92 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= 93 | github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= 94 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 95 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 96 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 97 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 98 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 99 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 100 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 101 | github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= 102 | github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 103 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 104 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 105 | github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= 106 | github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= 107 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 108 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 109 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 110 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 111 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 112 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 113 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 114 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 115 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 116 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 117 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 118 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 119 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 120 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 121 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 122 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 123 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 124 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 125 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 126 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 127 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 128 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 129 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 130 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 131 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 132 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 133 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 137 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 138 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 139 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 140 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 141 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 142 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 143 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 144 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 146 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 147 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 148 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 149 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 150 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 151 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 153 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 154 | gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= 155 | gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 156 | gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc= 157 | gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c= 158 | gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 159 | gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= 160 | gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= 161 | gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 162 | gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU= 163 | gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk= 164 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 165 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 166 | --------------------------------------------------------------------------------