Bläddra i källkod

feat: 编辑个人资料

runningwater 5 månader sedan
förälder
incheckning
3f701ef103

+ 29 - 29
app/cmd/serve.go

@@ -1,41 +1,41 @@
 package cmd
 
 import (
-    "github.com/gin-gonic/gin"
-    "github.com/spf13/cobra"
+	"github.com/gin-gonic/gin"
+	"github.com/spf13/cobra"
 
-    "github.com/runningwater/gohub/bootstrap"
-    "github.com/runningwater/gohub/pkg/config"
-    "github.com/runningwater/gohub/pkg/console"
-    "github.com/runningwater/gohub/pkg/logger"
+	"github.com/runningwater/gohub/bootstrap"
+	"github.com/runningwater/gohub/pkg/config"
+	"github.com/runningwater/gohub/pkg/console"
+	"github.com/runningwater/gohub/pkg/logger"
 )
 
 var CmdServe = &cobra.Command{
-    Use:   "serve",
-    Short: "Start web server",
-    Long:  "Start the server - Gin server",
-    Run:   runWeb,
-    Args:  cobra.NoArgs,
+	Use:   "serve",
+	Short: "Start web server",
+	Long:  "Start the server - Gin server",
+	Run:   runWeb,
+	Args:  cobra.NoArgs,
 }
 
 func runWeb(cmd *cobra.Command, args []string) {
-    // 设置 gin 的运行模式, 支持 debug, release, test
-    // release 模式会屏蔽调试信息,官方建议生产环境中使用
-    // 非 release 模式 gin 终端打印太多信息,干扰到我们程序中的 Log
-    // 故此设置为 release 模式
-    gin.SetMode(gin.ReleaseMode)
-
-    // gin 框架初始化
-    router := gin.New()
-
-    // 初始化路由绑定
-    bootstrap.SetupRoute(router)
-
-    // 运行服务
-    err := router.Run(":" + config.Get("app.port"))
-    if err != nil {
-        logger.ErrorString("CMD", "serve", err.Error())
-        console.Exit("Unable to start server, error:" + err.Error())
-    }
+	// 设置 gin 的运行模式, 支持 debug, release, test
+	// release 模式会屏蔽调试信息,官方建议生产环境中使用
+	// 非 release 模式 gin 终端打印太多信息,干扰到我们程序中的 Log
+	// 故此设置为 release 模式
+	gin.SetMode(gin.ReleaseMode)
+
+	// gin 框架初始化
+	router := gin.New()
+
+	// 初始化路由绑定
+	bootstrap.SetupRoute(router)
+
+	// 运行服务
+	err := router.Run(":" + config.Get("app.port"))
+	if err != nil {
+		logger.ErrorString("CMD", "serve", err.Error())
+		console.Exit("Unable to start server, error:" + err.Error())
+	}
 
 }

+ 18 - 1
app/http/controllers/api/v1/users_controller.go

@@ -21,7 +21,6 @@ func (ctrl *UsersController) CurrentUser(c *gin.Context) {
 
 // Index 所有用户
 func (ctrl *UsersController) Index(c *gin.Context) {
-	// data := user.All()
 	// 输入参数校验
 	request := requests.PaginationRequest{}
 	if ok := requests.Validate(c, &request, requests.Pagination); !ok {
@@ -33,3 +32,21 @@ func (ctrl *UsersController) Index(c *gin.Context) {
 		"pager": pager,
 	})
 }
+
+// UpdateProfile 更新用户信息
+func (ctrl *UsersController) UpdateProfile(c *gin.Context) {
+	request := requests.UserUpdateProfileRequest{}
+	if ok := requests.Validate(c, &request, requests.UserUpdateProfile); !ok {
+		return
+	}
+	currentUser := auth.CurrentUser(c)
+	currentUser.Name = request.Name
+	currentUser.City = request.City
+	currentUser.Introduction = request.Introduction
+	affected := currentUser.Save()
+	if affected > 0 {
+		response.Data(c, currentUser)
+	} else {
+		response.Abort500(c, "更新失败,请稍后尝试")
+	}
+}

+ 6 - 1
app/models/user/user_model.go

@@ -11,7 +11,12 @@ import (
 type User struct {
 	models.BaseModel
 
-	Name     string `json:"name,omitempty"`
+	Name string `json:"name,omitempty"`
+
+	City         string `json:"city,omitempty"`
+	Introduction string `json:"introduction,omitempty"`
+	Avatar       string `json:"avatar,omitempty"`
+
 	Email    string `json:"-"`
 	Phone    string `json:"-"`
 	Password string `json:"-"`

+ 41 - 0
app/requests/user_request.go

@@ -0,0 +1,41 @@
+package requests
+
+import (
+	"github.com/gin-gonic/gin"
+	"github.com/thedevsaddam/govalidator"
+
+	"github.com/runningwater/gohub/pkg/auth"
+)
+
+type UserUpdateProfileRequest struct {
+	Name         string `valid:"name" json:"name"`
+	City         string `valid:"city" json:"city"`
+	Introduction string `valid:"introduction" json:"introduction"`
+}
+
+func UserUpdateProfile(data any, c *gin.Context) map[string][]string {
+	// 查询用户名重复时,过滤当前用户ID
+	uid := auth.CurrentUID(c)
+	rules := govalidator.MapData{
+		"name":         []string{"required", "alpha_num", "between:3,20", "not_exists:users,name," + uid},
+		"city":         []string{"min_cn:2", "max_cn:20"},
+		"introduction": []string{"min_cn:4", "max_cn:240"},
+	}
+	messages := govalidator.MapData{
+		"name": []string{
+			"required:用户名不能为空",
+			"alpha_num:用户名格式错误",
+			"between:3,20:用户名长度需在 3-20 之间",
+			"not_exists:用户名已存在",
+		},
+		"city": []string{
+			"min_cn:城市长度需至少 2 个字符",
+			"max_cn:城市长度不能超过 20 个字符",
+		},
+		"introduction": []string{
+			"min_cn:简介长度需至少 4 个字符",
+			"max_cn:简介长度不能超过 240 个字符",
+		},
+	}
+	return validate(data, rules, messages)
+}

+ 41 - 0
database/migrations/2025_07_22_100425_add_fields_to_user.go

@@ -0,0 +1,41 @@
+package migrations
+
+import (
+	"gorm.io/gorm"
+
+	"github.com/runningwater/gohub/pkg/console"
+	"github.com/runningwater/gohub/pkg/migrate"
+)
+
+func init() {
+
+	type User struct {
+		City         string `gorm:"type:varchar(10);"`
+		Introduction string `gorm:"type:varchar(255);"`
+		Avatar       string `gorm:"type:varchar(255);default:null"`
+	}
+
+	up := func(migrator gorm.Migrator, DB *gorm.DB) {
+		_ = DB.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci").AutoMigrate(&User{})
+	}
+
+	down := func(migrator gorm.Migrator, DB *gorm.DB) {
+		err := migrator.DropColumn(&User{}, "city")
+		if err != nil {
+			console.Error(err.Error())
+			return
+		}
+		err = migrator.DropColumn(&User{}, "introduction")
+		if err != nil {
+			console.Error(err.Error())
+			return
+		}
+		err = migrator.DropColumn(&User{}, "avatar")
+		if err != nil {
+			console.Error(err.Error())
+			return
+		}
+	}
+
+	migrate.Add(up, down, "2025_07_22_100425_add_fields_to_user")
+}

+ 12 - 0
gohub.http

@@ -96,6 +96,18 @@ Authorization: Bearer {{access_token}}
 ### 用户列表
 GET {{base_url}}/v1/users HTTP/1.1
 
+### 更新用户信息
+PUT {{base_url}}/v1/users HTTP/1.1
+Authorization: Bearer {{access_token}}
+Content-Type: application/json
+
+{
+  "name": "simon",
+  "city": "杭州",
+  "introduction": "这里是个人描述"
+}
+
+
 ### 创建分类
 POST {{base_url}}/v1/categories HTTP/1.1
 Authorization: Bearer {{access_token}}

+ 162 - 162
pkg/jwt/jwt.go

@@ -1,222 +1,222 @@
 package jwt
 
 import (
-    "errors"
-    "strings"
-    "time"
+	"errors"
+	"strings"
+	"time"
 
-    "github.com/gin-gonic/gin"
-    jwtpkg "github.com/golang-jwt/jwt/v5"
+	"github.com/gin-gonic/gin"
+	jwtpkg "github.com/golang-jwt/jwt/v5"
 
-    "github.com/runningwater/gohub/pkg/app"
-    "github.com/runningwater/gohub/pkg/config"
-    "github.com/runningwater/gohub/pkg/logger"
+	"github.com/runningwater/gohub/pkg/app"
+	"github.com/runningwater/gohub/pkg/config"
+	"github.com/runningwater/gohub/pkg/logger"
 )
 
 var (
-    ErrTokenExpired           error = errors.New("令牌已过期")
-    ErrTokenNotValidYet       error = errors.New("令牌尚未生效")
-    ErrTokenMalformed         error = errors.New("令牌格式错误")
-    ErrTokenInvalid           error = errors.New("令牌无效")
-    ErrTokenExpiredMaxRefresh error = errors.New("令牌已过期,无法刷新")
-    ErrHeaderEmpty            error = errors.New("需要认证才能访问!")
-    ErrHeaderMalformed        error = errors.New("请求头格式错误")
+	ErrTokenExpired           error = errors.New("令牌已过期")
+	ErrTokenNotValidYet       error = errors.New("令牌尚未生效")
+	ErrTokenMalformed         error = errors.New("令牌格式错误")
+	ErrTokenInvalid           error = errors.New("令牌无效")
+	ErrTokenExpiredMaxRefresh error = errors.New("令牌已过期,无法刷新")
+	ErrHeaderEmpty            error = errors.New("需要认证才能访问!")
+	ErrHeaderMalformed        error = errors.New("请求头格式错误")
 )
 
 // JWT 结构体,包含签名密钥和刷新 Token 的最大过期时间
 type JWT struct {
-    // 签名密钥,用于生成和验证 JWT 签名,读取 app.key
-    SigningKey []byte
+	// 签名密钥,用于生成和验证 JWT 签名,读取 app.key
+	SigningKey []byte
 
-    // 刷新 Token 的最大过期时间
-    MaxRefresh time.Duration
+	// 刷新 Token 的最大过期时间
+	MaxRefresh time.Duration
 }
 
 // CustomClaims 自定义的 JWT 载荷结构体,包含用户 ID、用户名 和过期时间
 type CustomClaims struct {
-    // 用户 ID
-    UserID string `json:"user_id"`
-    // 用户名
-    UserName string `json:"user_name"`
-    // 过期时间
-    ExpireTime int64 `json:"expire_time"`
-
-    // 标准的 JWT 载荷字段
-    // Audience aud : 受众
-    // Issuer iss : 签发者
-    // Not Before nbf : 生效时间
-    // Issued At iat : 签发时间
-    // JWT ID jti : JWT ID 编号
-    // Subject sub : 主题
-    // Expires At exp : 过期时间
-    // 标准的 JWT 载荷字段
-    jwtpkg.RegisteredClaims
+	// 用户 ID
+	UserID string `json:"user_id"`
+	// 用户名
+	UserName string `json:"user_name"`
+	// 过期时间
+	ExpireTime int64 `json:"expire_time"`
+
+	// 标准的 JWT 载荷字段
+	// Audience aud : 受众
+	// Issuer iss : 签发者
+	// Not Before nbf : 生效时间
+	// Issued At iat : 签发时间
+	// JWT ID jti : JWT ID 编号
+	// Subject sub : 主题
+	// Expires At exp : 过期时间
+	// 标准的 JWT 载荷字段
+	jwtpkg.RegisteredClaims
 }
 
 func NewJWT() *JWT {
-    return &JWT{
-        SigningKey: []byte(config.GetString("app.key")),
-        MaxRefresh: time.Duration(config.GetInt64("jwt.max_refresh_time")) * time.Minute,
-    }
+	return &JWT{
+		SigningKey: []byte(config.GetString("app.key")),
+		MaxRefresh: time.Duration(config.GetInt64("jwt.max_refresh_time")) * time.Minute,
+	}
 }
 
 // ParseToken 解析 Token
 func (j *JWT) ParseToken(c *gin.Context) (*CustomClaims, error) {
 
-    // 1. 获取请求头中的 Token
-    tokenString, err := j.getTokenFromHeader(c)
-    if err != nil {
-        return nil, err
-    }
-
-    // 2. 调用 jwt 库解析用户传入的 token
-    token, err := j.parseTokenString(tokenString)
-
-    // 3. 解析出错, 未报错证明是合法的 token
-    if err != nil {
-        return nil, err
-        // validationErr, ok := err.(*jwtpkg.ValidationError)
-        // if ok {
-        // 	switch validationErr.Errors {
-        // 	case jwtpkg.ValidationErrorMalformed:
-        // 		return nil, ErrTokenMalformed
-        // 	case jwtpkg.ValidationErrorExpired:
-        // 		return nil, ErrTokenExpired
-        // 	default:
-        // 		return nil, ErrTokenInvalid
-        // 	}
-        // }
-
-        // }
-        // return nil, ErrTokenInvalid
-    }
-
-    if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
-        return claims, nil
-    }
-    return nil, ErrTokenInvalid
+	// 1. 获取请求头中的 Token
+	tokenString, err := j.getTokenFromHeader(c)
+	if err != nil {
+		return nil, err
+	}
+
+	// 2. 调用 jwt 库解析用户传入的 token
+	token, err := j.parseTokenString(tokenString)
+
+	// 3. 解析出错, 未报错证明是合法的 token
+	if err != nil {
+		return nil, err
+		// validationErr, ok := err.(*jwtpkg.ValidationError)
+		// if ok {
+		// 	switch validationErr.Errors {
+		// 	case jwtpkg.ValidationErrorMalformed:
+		// 		return nil, ErrTokenMalformed
+		// 	case jwtpkg.ValidationErrorExpired:
+		// 		return nil, ErrTokenExpired
+		// 	default:
+		// 		return nil, ErrTokenInvalid
+		// 	}
+		// }
+
+		// }
+		// return nil, ErrTokenInvalid
+	}
+
+	if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
+		return claims, nil
+	}
+	return nil, ErrTokenInvalid
 
 }
 
 // IssueToken 签发 Token
 func (j *JWT) IssueToken(userID, userName string) string {
 
-    // 1. 构造用户 claims 信息(负荷)
-    expireTime := j.expireAtTime()
-    claims := CustomClaims{
-        userID,
-        userName,
-        expireTime.Unix(),
-        jwtpkg.RegisteredClaims{
-            NotBefore: jwtpkg.NewNumericDate(app.TimenowInTimezone()), // 生效时间
-            IssuedAt:  jwtpkg.NewNumericDate(app.TimenowInTimezone()), // 签发时间
-            ExpiresAt: jwtpkg.NewNumericDate(expireTime),              // 过期时间
-            Issuer:    config.GetString("app.name"),                   // 签发者
-            Subject:   userID,                                         // 主题
-            Audience:  []string{userName},                             // 受众
-        },
-    }
-    // 2. 根据 claims 生成 Token
-    token, err := j.generateToken(claims)
-    if err != nil {
-        logger.LogIf(err)
-        return ""
-    }
-    return token
+	// 1. 构造用户 claims 信息(负荷)
+	expireTime := j.expireAtTime()
+	claims := CustomClaims{
+		userID,
+		userName,
+		expireTime.Unix(),
+		jwtpkg.RegisteredClaims{
+			NotBefore: jwtpkg.NewNumericDate(app.TimenowInTimezone()), // 生效时间
+			IssuedAt:  jwtpkg.NewNumericDate(app.TimenowInTimezone()), // 签发时间
+			ExpiresAt: jwtpkg.NewNumericDate(expireTime),              // 过期时间
+			Issuer:    config.GetString("app.name"),                   // 签发者
+			Subject:   userID,                                         // 主题
+			Audience:  []string{userName},                             // 受众
+		},
+	}
+	// 2. 根据 claims 生成 Token
+	token, err := j.generateToken(claims)
+	if err != nil {
+		logger.LogIf(err)
+		return ""
+	}
+	return token
 }
 
 // RefreshToken 刷新 Token
 func (j *JWT) RefreshToken(c *gin.Context) (string, error) {
-    // 1. 从 Header 里获取 token
-    tokenString, err := j.getTokenFromHeader(c)
-    if err != nil {
-        return "", err
-    }
-    // 2. 调用 jwt 库解析用户传入的 token
-    token, err := j.parseTokenString(tokenString)
-    // 3. 解析出错, 未报错证明是合法的 token
-    if err != nil {
-        // validationErr, ok := err.(*jwtpkg.ValidationError)
-        // // 满足刷新条件:只是单一的报错 ValidationErrorExpired
-        // if !ok || validationErr.Errors != jwtpkg.ValidationErrorExpired {
-        // 	return "", err
-        // }
-        if errors.Is(err, jwtpkg.ErrTokenExpired) {
-            // 过期了,返回错误
-            return "", err
-        }
-    }
-
-    // 4. 解析 claims
-    claims := token.Claims.(*CustomClaims)
-
-    // 5. 判断是否过了最大允许刷新的时间
-    x := app.TimenowInTimezone().Add(-j.MaxRefresh).Unix()
-    if claims.IssuedAt.Unix() > x {
-        // 修改过期时间
-        claims.ExpiresAt = jwtpkg.NewNumericDate(j.expireAtTime())
-        return j.generateToken(*claims)
-    }
-
-    // 6. 超过最大允许刷新的时间,返回错误
-    return "", ErrTokenExpiredMaxRefresh
+	// 1. 从 Header 里获取 token
+	tokenString, err := j.getTokenFromHeader(c)
+	if err != nil {
+		return "", err
+	}
+	// 2. 调用 jwt 库解析用户传入的 token
+	token, err := j.parseTokenString(tokenString)
+	// 3. 解析出错, 未报错证明是合法的 token
+	if err != nil {
+		// validationErr, ok := err.(*jwtpkg.ValidationError)
+		// // 满足刷新条件:只是单一的报错 ValidationErrorExpired
+		// if !ok || validationErr.Errors != jwtpkg.ValidationErrorExpired {
+		// 	return "", err
+		// }
+		if errors.Is(err, jwtpkg.ErrTokenExpired) {
+			// 过期了,返回错误
+			return "", err
+		}
+	}
+
+	// 4. 解析 claims
+	claims := token.Claims.(*CustomClaims)
+
+	// 5. 判断是否过了最大允许刷新的时间
+	x := app.TimenowInTimezone().Add(-j.MaxRefresh).Unix()
+	if claims.IssuedAt.Unix() > x {
+		// 修改过期时间
+		claims.ExpiresAt = jwtpkg.NewNumericDate(j.expireAtTime())
+		return j.generateToken(*claims)
+	}
+
+	// 6. 超过最大允许刷新的时间,返回错误
+	return "", ErrTokenExpiredMaxRefresh
 }
 
 // generateToken 生成 Token, 内部使用,外部请调用 IssueToken
 func (j *JWT) generateToken(claims CustomClaims) (string, error) {
 
-    // 创建一个新的 Token 对象,使用 HMAC SHA256 签名方法
-    token := jwtpkg.NewWithClaims(jwtpkg.SigningMethodHS256, claims)
+	// 创建一个新的 Token 对象,使用 HMAC SHA256 签名方法
+	token := jwtpkg.NewWithClaims(jwtpkg.SigningMethodHS256, claims)
 
-    return token.SignedString(j.SigningKey)
+	return token.SignedString(j.SigningKey)
 }
 
 // expireAtTime 计算过期时间
 func (j *JWT) expireAtTime() time.Time {
-    timeNow := app.TimenowInTimezone()
+	timeNow := app.TimenowInTimezone()
 
-    var expireTime int64
-    if config.GetBool("app.debug") {
-        expireTime = config.GetInt64("jwt.debug_expire_time")
-    } else {
-        expireTime = config.GetInt64("jwt.expire_time")
-    }
+	var expireTime int64
+	if config.GetBool("app.debug") {
+		expireTime = config.GetInt64("jwt.debug_expire_time")
+	} else {
+		expireTime = config.GetInt64("jwt.expire_time")
+	}
 
-    expire := time.Duration(expireTime) * time.Minute
+	expire := time.Duration(expireTime) * time.Minute
 
-    return timeNow.Add(expire)
+	return timeNow.Add(expire)
 }
 
 // getTokenFromHeader 获取请求头中的 Token
 // Authorization: Bearer Token
 // 从请求头中获取 Token,格式为 "Bearer Token"
 func (j *JWT) getTokenFromHeader(c *gin.Context) (string, error) {
-    // 获取请求头中的 Token
-    authHeader := c.Request.Header.Get("Authorization")
-    if authHeader == "" {
-        return "", ErrHeaderEmpty
-    }
-
-    // 检查请求头格式是否正确
-    // Bearer Token
-    // Bearer 后面有一个空格
-    parts := strings.SplitN(authHeader, " ", 2)
-    if len(parts) < 2 || parts[0] != "Bearer" {
-        return "", ErrHeaderMalformed
-    }
-
-    // 返回 Token 字符串
-    return parts[1], nil
+	// 获取请求头中的 Token
+	authHeader := c.Request.Header.Get("Authorization")
+	if authHeader == "" {
+		return "", ErrHeaderEmpty
+	}
+
+	// 检查请求头格式是否正确
+	// Bearer Token
+	// Bearer 后面有一个空格
+	parts := strings.SplitN(authHeader, " ", 2)
+	if len(parts) < 2 || parts[0] != "Bearer" {
+		return "", ErrHeaderMalformed
+	}
+
+	// 返回 Token 字符串
+	return parts[1], nil
 }
 
 // parseTokenString 解析 Token 字符串
 func (j *JWT) parseTokenString(tokenString string) (*jwtpkg.Token, error) {
 
-    return jwtpkg.ParseWithClaims(
-        tokenString,
-        &CustomClaims{},
-        func(token *jwtpkg.Token) (any, error) {
-            return j.SigningKey, nil
-        },
-    )
+	return jwtpkg.ParseWithClaims(
+		tokenString,
+		&CustomClaims{},
+		func(token *jwtpkg.Token) (any, error) {
+			return j.SigningKey, nil
+		},
+	)
 }

+ 1 - 0
routes/api.go

@@ -57,6 +57,7 @@ func RegisterAPIRoutes(router *gin.Engine) {
 		userGroup := v1.Group("/users")
 		{
 			userGroup.GET("", uc.Index)
+			userGroup.PUT("", middlewares.AuthJWT(), uc.UpdateProfile)
 		}
 		cgc := new(controllers.CategoriesController)
 		categoryGroup := v1.Group("/categories")