runningwater пре 5 месеци
родитељ
комит
8c6699e87a

+ 1 - 0
.gitignore

@@ -3,6 +3,7 @@ tmp
 gohub
 .DS_Store
 .history
+*.rdb
 
 # Golang #
 ######################

+ 28 - 28
LICENSE

@@ -1,28 +1,28 @@
-BSD 3-Clause License
-
-Copyright (c) 2025, runningwater
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-1. Redistributions of source code must retain the above copyright notice, this
-   list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright notice,
-   this list of conditions and the following disclaimer in the documentation
-   and/or other materials provided with the distribution.
-
-3. Neither the name of the copyright holder nor the names of its
-   contributors may be used to endorse or promote products derived from
-   this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+BSD 3-Clause License
+
+Copyright (c) 2025, runningwater
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 4 - 0
README.md

@@ -52,6 +52,7 @@ UNIQUE KEY `migration` (`migration`)
 
 #### 🐛 Bug 修复
 
+- Fix format
 - Fix typing error
 - Fix automigrate charset for mysql
 - Golangci-lint run fix
@@ -76,6 +77,9 @@ UNIQUE KEY `migration` (`migration`)
 
 #### 🚀 新功能
 
+- 上传头像
+- 编辑个人资料
+- 编辑个人资料
 - *(Command)* Cache forget 命令
 - *(Command)* Cache clear 命令
 - 缓存友情链接列表

+ 37 - 0
app/http/controllers/api/v1/users_controller.go

@@ -4,6 +4,8 @@ import (
 	"github.com/runningwater/gohub/app/models/user"
 	"github.com/runningwater/gohub/app/requests"
 	"github.com/runningwater/gohub/pkg/auth"
+	"github.com/runningwater/gohub/pkg/config"
+	"github.com/runningwater/gohub/pkg/file"
 	"github.com/runningwater/gohub/pkg/response"
 
 	"github.com/gin-gonic/gin"
@@ -50,3 +52,38 @@ func (ctrl *UsersController) UpdateProfile(c *gin.Context) {
 		response.Abort500(c, "更新失败,请稍后尝试")
 	}
 }
+
+// UpdateEmail 更新用户邮箱
+func (ctrl *UsersController) UpdateEmail(c *gin.Context) {
+	request := requests.UserUpdateEmailRequest{}
+	if ok := requests.Validate(c, &request, requests.UserUpdateEmail); !ok {
+		return
+	}
+	currentUser := auth.CurrentUser(c)
+	currentUser.Email = request.Email
+	affected := currentUser.Save()
+	if affected > 0 {
+		response.Success(c)
+		return
+	}
+	response.Abort500(c, "更新失败,请稍后尝试")
+}
+
+// UpdateAvatar 更新用户头像
+func (ctrl *UsersController) UpdateAvatar(c *gin.Context) {
+	request := requests.UserUpdateAvatarRequest{}
+	if ok := requests.Validate(c, &request, requests.UserUpdateAvatar); !ok {
+		return
+	}
+	avatar, err := file.SaveUploadAvatar(c, request.Avatar)
+	if err != nil {
+		response.Abort500(c, "上传头像失败,请稍后尝试")
+		return
+	}
+
+	currentUser := auth.CurrentUser(c)
+	currentUser.Avatar = config.GetString("app.url") + "/" + avatar
+	_ = currentUser.Save()
+
+	response.Data(c, currentUser)
+}

+ 27 - 6
app/requests/requests.go

@@ -3,8 +3,9 @@ package requests
 
 import (
 	"github.com/gin-gonic/gin"
-	"github.com/runningwater/gohub/pkg/response"
 	"github.com/thedevsaddam/govalidator"
+
+	"github.com/runningwater/gohub/pkg/response"
 )
 
 // ValidatorFunc 定义验证函数类型
@@ -52,11 +53,12 @@ func Validate(c *gin.Context, obj any, handler ValidatorFunc) bool {
 }
 
 // validate 函数用于执行数据验证
-// 它接受数据、验证规则和自定义错误信息,并返回验证结果
-// data: 需要验证的数据
-// rules: 验证规则
-// messages: 自定义错误信息
-// 返回值是一个映射,包含字段名和对应的错误信息
+//
+//	它接受数据、验证规则和自定义错误信息,并返回验证结果
+//	data: 需要验证的数据
+//	rules: 验证规则
+//	messages: 自定义错误信息
+//	返回值是一个映射,包含字段名和对应的错误信息
 func validate(data any, rules, messages govalidator.MapData) map[string][]string {
 
 	opts := govalidator.Options{
@@ -68,3 +70,22 @@ func validate(data any, rules, messages govalidator.MapData) map[string][]string
 
 	return govalidator.New(opts).ValidateStruct()
 }
+
+// validateFile 函数用于验证文件上传
+//
+//	它接受上下文对象、数据、验证规则和自定义错误信息,并返回验证结果
+//	c: gin.Context 上下文对象
+//	data: 需要验证的数据
+//	rules: 验证规则
+//	messages: 自定义错误信息
+//	返回值是一个映射,包含字段名和对应的错误信息
+func validateFile(c *gin.Context, data any, rules, messages govalidator.MapData) map[string][]string {
+	opts := govalidator.Options{
+		Request:       c.Request,
+		Rules:         rules,
+		TagIdentifier: "valid", // 使用结构体中的valid标签
+		Messages:      messages,
+	}
+
+	return govalidator.New(opts).Validate()
+}

+ 94 - 66
app/requests/user_request.go

@@ -1,87 +1,115 @@
 package requests
 
 import (
-    "github.com/gin-gonic/gin"
-    "github.com/thedevsaddam/govalidator"
+	"mime/multipart"
 
-    "github.com/runningwater/gohub/app/requests/validators"
-    "github.com/runningwater/gohub/pkg/auth"
+	"github.com/gin-gonic/gin"
+	"github.com/thedevsaddam/govalidator"
+
+	"github.com/runningwater/gohub/app/requests/validators"
+	"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"`
+	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)
+	// 查询用户名重复时,过滤当前用户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)
 }
 
 type UserUpdateEmailRequest struct {
-    Email      string `valid:"email" json:"email"`
-    VerifyCode string `valid:"verify_code" json:"verify_code,omitempty"`
+	Email      string `valid:"email" json:"email"`
+	VerifyCode string `valid:"verify_code" json:"verify_code,omitempty"`
 }
 
 // UserUpdateEmail 处理用户邮箱更新请求
 // 参数:
-//   data - 请求数据,应为 UserUpdateEmailRequest 类型
-//   c - Gin 上下文对象
+//
+//	data - 请求数据,应为 UserUpdateEmailRequest 类型
+//	c - Gin 上下文对象
+//
 // 返回值:
-//   map[string][]string - 包含验证错误的映射
+//
+//	map[string][]string - 包含验证错误的映射
 func UserUpdateEmail(data any, c *gin.Context) map[string][]string {
-    currentUser := auth.CurrentUser(c)
-    rules := govalidator.MapData{
-        "email": []string{
-            "required",
-            "min:4",
-            "max:30",
-            "email",
-            "not_exists:users,email," + currentUser.GetStringID(),
-            "not_in:" + currentUser.Email,
-        },
-        "verify_code": []string{"required", "digits:6"},
-    }
-    messages := govalidator.MapData{
-        "email": []string{
-            "required:邮箱不能为空",
-            "min:邮箱长度需至少 4 个字符",
-            "max:邮箱长度不能超过 30 个字符",
-            "email:邮箱格式错误",
-            "not_exists:邮箱已存在",
-            "not_in:新邮箱与当前邮箱相同",
-        },
-        "verify_code": []string{
-            "required:验证码不能为空",
-            "digits:6:验证码长度必须为 6 位数字",
-        },
-    }
-    errs := validate(data, rules, messages)
-    _data := data.(*UserUpdateEmailRequest)
-    errs = validators.ValidateVerifyCode(_data.Email, _data.VerifyCode, errs)
+	currentUser := auth.CurrentUser(c)
+	rules := govalidator.MapData{
+		"email": []string{
+			"required",
+			"min:4",
+			"max:30",
+			"email",
+			"not_exists:users,email," + currentUser.GetStringID(),
+			"not_in:" + currentUser.Email,
+		},
+		"verify_code": []string{"required", "digits:6"},
+	}
+	messages := govalidator.MapData{
+		"email": []string{
+			"required:邮箱不能为空",
+			"min:邮箱长度需至少 4 个字符",
+			"max:邮箱长度不能超过 30 个字符",
+			"email:邮箱格式错误",
+			"not_exists:邮箱已存在",
+			"not_in:新邮箱与当前邮箱相同",
+		},
+		"verify_code": []string{
+			"required:验证码不能为空",
+			"digits:6:验证码长度必须为 6 位数字",
+		},
+	}
+	errs := validate(data, rules, messages)
+	_data := data.(*UserUpdateEmailRequest)
+	errs = validators.ValidateVerifyCode(_data.Email, _data.VerifyCode, errs)
+
+	return errs
+}
+
+type UserUpdateAvatarRequest struct {
+	Avatar *multipart.FileHeader `valid:"avatar" form:"avatar"`
+}
 
-    return errs
+func UserUpdateAvatar(data any, c *gin.Context) map[string][]string {
+	// size 的单位为 bytes
+	// - 1024 bytes 为 1 KB
+	// - 1024 * 1024 bytes 为 1 MB
+	// - 1024 * 1024 * 1024 bytes 为 1 GB
+	// - 20971520 bytes 为 20 MB
+	rules := govalidator.MapData{
+		"file:avatar": []string{"ext:png,jpg,jpeg", "size:20971520", "required"},
+	}
+	messages := govalidator.MapData{
+		"file:avatar": []string{
+			"ext:头像格式错误,只能为 png,jpg,jpeg 一种",
+			"siz:头像大小不能超过 20 MB",
+			"required:头像不能为空",
+		},
+	}
+	return validateFile(c, data, rules, messages)
 }

+ 10 - 4
gohub.http

@@ -55,7 +55,7 @@ POST {{base_url}}/v1/auth/signup/using-email HTTP/1.1
 Content-Type: application/json
 
 {
-  "name": "summer3",
+  "name": "summer",
   "password": "secret",
   "password_confirm": "secret",
   "verify_code": "123123",
@@ -158,11 +158,17 @@ Content-Type: application/json
   "body": "话题1内容, 这里是帖子描述内容帖子描述内容",
   "category_id": "3"
 }
-
-### 显示话题
-GET {{base_url}}/v1/topics/2 HTTP/1.1
+### 更新邮箱
+POST {{base_url}}/v1/users/email HTTP/1.1
+Authorization: Bearer {{access_token}}
 Content-Type: application/json
 
+{
+  "email": "summer@testing.com",
+  "verify_code": "123123"
+}
+
+
 ### 话题列表
 GET {{base_url}}/v1/topics HTTP/1.1
 Content-Type: application/json

+ 1 - 1
http-client.env.json

@@ -1,6 +1,6 @@
 {
   "dev": {
     "base_url": "http://127.0.0.1:3000",
-    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsInVzZXJfbmFtZSI6InN1bW1lciIsImV4cGlyZV90aW1lIjoxNzU3ODE2NDI3LCJpc3MiOiJHb2h1YiIsInN1YiI6IjEiLCJhdWQiOlsic3VtbWVyIl0sImV4cCI6MTc1NzgxNjQyNywibmJmIjoxNzUyNjMyNDI3LCJpYXQiOjE3NTI2MzI0Mjd9.9tYiBN5J4PVblgM1vk09s8sPq2Hqsvm5YimJcV19iaw"
+    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIiLCJ1c2VyX25hbWUiOiJzdW1tZXIiLCJleHBpcmVfdGltZSI6MTc1ODkzNjk2OCwiaXNzIjoiR29odWIiLCJzdWIiOiIxMiIsImF1ZCI6WyJzdW1tZXIiXSwiZXhwIjoxNzU4OTM2OTY4LCJuYmYiOjE3NTM3NTI5NjgsImlhdCI6MTc1Mzc1Mjk2OH0.3n3ki71OUZWH66fC1u2Qno1lugWJz-lJW54etfXpViU"
   }
 }

+ 31 - 0
pkg/file/file.go

@@ -1,9 +1,17 @@
 package file
 
 import (
+	"fmt"
+	"mime/multipart"
 	"os"
 	"path/filepath"
 	"strings"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/runningwater/gohub/pkg/app"
+	"github.com/runningwater/gohub/pkg/auth"
+	"github.com/runningwater/gohub/pkg/helpers"
 )
 
 // Put 将数据写入文件
@@ -32,3 +40,26 @@ func Exists(path string) bool {
 func NameWithoutExtension(fileName string) string {
 	return strings.TrimSuffix(fileName, filepath.Ext(fileName))
 }
+
+func SaveUploadAvatar(c *gin.Context, file *multipart.FileHeader) (string, error) {
+
+	var avatar string
+	// 确保目录存在,不存在创建
+	publicPath := "public"
+	dirName := fmt.Sprintf("/uploads/avatars/%s/%s/", app.TimenowInTimezone().Format("2006/01/02"), auth.CurrentUID(c))
+	if err := os.MkdirAll(publicPath+dirName, 0755); err != nil {
+		return "", err
+	}
+	// 保存文件
+	fileName := randomNameFromUploadFile(file)
+	avatarPath := publicPath + dirName + fileName
+	if err := c.SaveUploadedFile(file, avatarPath); err != nil {
+		return avatar, err
+	}
+
+	return avatarPath, nil
+}
+
+func randomNameFromUploadFile(file *multipart.FileHeader) string {
+	return helpers.RandomString(16) + filepath.Ext(file.Filename)
+}

+ 1 - 1
pkg/helpers/helpers.go

@@ -47,7 +47,7 @@ func MicrosecondsStr(elapsed time.Duration) string {
 	return fmt.Sprintf("%.3fms", float64(elapsed.Nanoseconds())/1e6)
 }
 
-// 生成随机数字符串
+// RandomNumber 生成随机数字符串
 // length: 字符串长度
 func RandomNumber(length int) string {
 	table := [...]byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}

+ 2 - 0
public/uploads/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 2 - 0
routes/api.go

@@ -58,6 +58,8 @@ func RegisterAPIRoutes(router *gin.Engine) {
 		{
 			userGroup.GET("", uc.Index)
 			userGroup.PUT("", middlewares.AuthJWT(), uc.UpdateProfile)
+			userGroup.POST("/email", middlewares.AuthJWT(), uc.UpdateEmail)
+			userGroup.POST("/avatar", middlewares.AuthJWT(), uc.UpdateAvatar)
 		}
 		cgc := new(controllers.CategoriesController)
 		categoryGroup := v1.Group("/categories")