From fd9e54e9f42fe7345230b8f7ad42e6168a9661f8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 24 Dec 2025 22:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E9=AA=8C=E8=AF=81=E5=92=8C=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/http/web/auth.go | 66 ++++++++++++++++++++++++++++++ backend/app/http/web/dto/auth.go | 12 ++++++ backend/app/http/web/routes.gen.go | 5 +++ backend/app/middlewares/user.go | 19 ++++++--- backend/docs/docs.go | 54 ++++++++++++++++++++++++ backend/docs/swagger.json | 54 ++++++++++++++++++++++++ backend/docs/swagger.yaml | 36 ++++++++++++++++ 7 files changed, 240 insertions(+), 6 deletions(-) diff --git a/backend/app/http/web/auth.go b/backend/app/http/web/auth.go index d5fe5b0..4e97d7c 100644 --- a/backend/app/http/web/auth.go +++ b/backend/app/http/web/auth.go @@ -1,13 +1,19 @@ package web import ( + "errors" + "regexp" + "strings" + "quyun/v2/app/errorx" "quyun/v2/app/http/web/dto" "quyun/v2/app/services" + "quyun/v2/database/models" "quyun/v2/pkg/consts" "quyun/v2/providers/jwt" "github.com/gofiber/fiber/v3" + "gorm.io/gorm" ) // @provider @@ -15,6 +21,8 @@ type auth struct { jwt *jwt.JWT } +var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) + // Login 用户登录(平台侧,非超级管理员)。 // // @Summary 用户登录 @@ -44,6 +52,64 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, return &dto.LoginResponse{Token: token}, nil } +// Register 用户注册(平台侧,非超级管理员)。 +// +// @Summary 用户注册 +// @Tags Web +// @Accept json +// @Produce json +// @Param form body dto.RegisterForm true "form" +// @Success 200 {object} dto.LoginResponse "成功" +// @Router /v1/auth/register [post] +// @Bind form body +func (ctl *auth) register(ctx fiber.Ctx, form *dto.RegisterForm) (*dto.LoginResponse, error) { + username := strings.TrimSpace(form.Username) + if username == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入用户名") + } + if !reUsername.MatchString(username) { + return nil, errorx.ErrInvalidParameter.WithMsg("用户名需为 3-32 位字母/数字/下划线") + } + if form.Password == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入密码") + } + if len(form.Password) < 8 { + return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") + } + if form.Password != form.ConfirmPassword { + return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") + } + + // 先查询用户名是否已存在,避免直接插入导致不友好的数据库错误信息。 + _, err := services.User.FindByUsername(ctx, username) + if err == nil { + return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") + } + + m := &models.User{ + Username: username, + Password: form.Password, + Roles: []consts.Role{consts.RoleUser}, + Status: consts.UserStatusVerified, + } + if _, err := services.User.Create(ctx, m); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") + } + return nil, errorx.Wrap(err).WithMsg("注册失败,请稍后再试") + } + + token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{UserID: m.ID})) + if err != nil { + return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") + } + + return &dto.LoginResponse{Token: token}, nil +} + // Token 刷新登录凭证。 // // @Summary 刷新 Token diff --git a/backend/app/http/web/dto/auth.go b/backend/app/http/web/dto/auth.go index 0ab17bc..6085554 100644 --- a/backend/app/http/web/dto/auth.go +++ b/backend/app/http/web/dto/auth.go @@ -9,6 +9,18 @@ type LoginForm struct { Password string `json:"password,omitempty"` } +// RegisterForm 平台侧用户注册表单(用于创建用户并获取 JWT 访问凭证)。 +type RegisterForm struct { + // Username 用户名;需全局唯一(users.username);建议仅允许字母/数字/下划线,且长度在合理范围内。 + Username string `json:"username,omitempty"` + // Password 明文密码;后端会在创建用户时自动加密(bcrypt)。 + Password string `json:"password,omitempty"` + // ConfirmPassword 确认密码;必须与 Password 一致,避免误输入导致无法登录。 + ConfirmPassword string `json:"confirmPassword,omitempty"` + // VerifyCode 验证码(预留字段);当前版本仅透传/占位,不做后端校验。 + VerifyCode string `json:"verifyCode,omitempty"` +} + // LoginResponse 登录响应。 type LoginResponse struct { // Token JWT 访问令牌;前端应以 `Authorization: Bearer ` 方式携带。 diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go index d53446f..c1b2d31 100644 --- a/backend/app/http/web/routes.gen.go +++ b/backend/app/http/web/routes.gen.go @@ -52,6 +52,11 @@ func (r *Routes) Register(router fiber.Router) { r.auth.login, Body[dto.LoginForm]("form"), )) + r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register") + router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1( + r.auth.register, + Body[dto.RegisterForm]("form"), + )) // Register routes for controller: me r.log.Debugf("Registering route: Get /v1/me -> me.me") router.Get("/v1/me"[len(r.Path()):], DataFunc0( diff --git a/backend/app/middlewares/user.go b/backend/app/middlewares/user.go index b6b87ff..5ebf68c 100644 --- a/backend/app/middlewares/user.go +++ b/backend/app/middlewares/user.go @@ -10,17 +10,24 @@ import ( "github.com/gofiber/fiber/v3" ) -func shouldSkipUserJWTAuth(path string) bool { - // 登录接口无需鉴权。 - if strings.Contains(path, "/v1/auth/login") { - return true +func shouldSkipUserJWTAuth(path string, method string) bool { + // 仅对明确的公开接口放行,避免误伤其它路径。 + if method != fiber.MethodPost { + return false + } + + p := strings.TrimSuffix(path, "/") + switch p { + case "/v1/auth/login", "/v1/auth/register": + return true + default: + return false } - return false } // UserAuth 为平台通用(非租户域)接口提供 JWT 校验,并写入 claims 到 ctx locals。 func (f *Middlewares) UserAuth(c fiber.Ctx) error { - if shouldSkipUserJWTAuth(c.Path()) { + if shouldSkipUserJWTAuth(c.Path(), c.Method()) { f.log.Debug("middlewares.user.auth.skipped") return c.Next() } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 46603ee..37b23bf 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3885,6 +3885,39 @@ const docTemplate = `{ } } }, + "/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.RegisterForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, "/v1/auth/token": { "get": { "consumes": [ @@ -4906,6 +4939,27 @@ const docTemplate = `{ } } }, + "dto.RegisterForm": { + "type": "object", + "properties": { + "confirmPassword": { + "description": "ConfirmPassword 确认密码;必须与 Password 一致,避免误输入导致无法登录。", + "type": "string" + }, + "password": { + "description": "Password 明文密码;后端会在创建用户时自动加密(bcrypt)。", + "type": "string" + }, + "username": { + "description": "Username 用户名;需全局唯一(users.username);建议仅允许字母/数字/下划线,且长度在合理范围内。", + "type": "string" + }, + "verifyCode": { + "description": "VerifyCode 验证码(预留字段);当前版本仅透传/占位,不做后端校验。", + "type": "string" + } + } + }, "dto.SuperContentItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index be40fe2..cff9ee3 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3879,6 +3879,39 @@ } } }, + "/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.RegisterForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, "/v1/auth/token": { "get": { "consumes": [ @@ -4900,6 +4933,27 @@ } } }, + "dto.RegisterForm": { + "type": "object", + "properties": { + "confirmPassword": { + "description": "ConfirmPassword 确认密码;必须与 Password 一致,避免误输入导致无法登录。", + "type": "string" + }, + "password": { + "description": "Password 明文密码;后端会在创建用户时自动加密(bcrypt)。", + "type": "string" + }, + "username": { + "description": "Username 用户名;需全局唯一(users.username);建议仅允许字母/数字/下划线,且长度在合理范围内。", + "type": "string" + }, + "verifyCode": { + "description": "VerifyCode 验证码(预留字段);当前版本仅透传/占位,不做后端校验。", + "type": "string" + } + } + }, "dto.SuperContentItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d3349dd..2e3e80e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -685,6 +685,21 @@ definitions: description: Order is the created or existing order record (may be nil for owner/free-path without order). type: object + dto.RegisterForm: + properties: + confirmPassword: + description: ConfirmPassword 确认密码;必须与 Password 一致,避免误输入导致无法登录。 + type: string + password: + description: Password 明文密码;后端会在创建用户时自动加密(bcrypt)。 + type: string + username: + description: Username 用户名;需全局唯一(users.username);建议仅允许字母/数字/下划线,且长度在合理范围内。 + type: string + verifyCode: + description: VerifyCode 验证码(预留字段);当前版本仅透传/占位,不做后端校验。 + type: string + type: object dto.SuperContentItem: properties: content: @@ -4208,6 +4223,27 @@ paths: summary: 用户登录 tags: - Web + /v1/auth/register: + post: + consumes: + - application/json + parameters: + - description: form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.RegisterForm' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/quyun_v2_app_http_web_dto.LoginResponse' + summary: 用户注册 + tags: + - Web /v1/auth/token: get: consumes: