diff --git a/backend/app/http/web/auth.go b/backend/app/http/web/auth.go
index 4e97d7c..2ed919e 100644
--- a/backend/app/http/web/auth.go
+++ b/backend/app/http/web/auth.go
@@ -1,9 +1,14 @@
package web
import (
+ "crypto/rand"
"errors"
+ "fmt"
+ "math/big"
"regexp"
"strings"
+ "sync"
+ "time"
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
@@ -13,6 +18,7 @@ import (
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
+ "github.com/google/uuid"
"gorm.io/gorm"
)
@@ -21,7 +27,33 @@ type auth struct {
jwt *jwt.JWT
}
-var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
+var (
+ reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
+ rePhone = regexp.MustCompile(`^[0-9]{6,20}$`)
+)
+
+type passwordResetState struct {
+ code string
+ codeExpire time.Time
+ lastSentAt time.Time
+ resetToken string
+ tokenExpire time.Time
+}
+
+var passwordResetStore = struct {
+ mu sync.Mutex
+ phoneToItem map[string]*passwordResetState
+ tokenToPhone map[string]string
+}{
+ phoneToItem: make(map[string]*passwordResetState),
+ tokenToPhone: make(map[string]string),
+}
+
+const (
+ passwordResetCodeTTL = 5 * time.Minute
+ passwordResetTokenTTL = 10 * time.Minute
+ passwordResetSendGap = 60 * time.Second
+)
// Login 用户登录(平台侧,非超级管理员)。
//
@@ -52,6 +84,165 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse,
return &dto.LoginResponse{Token: token}, nil
}
+// PasswordResetSendSMS 找回密码:发送短信验证码(预留:当前返回验证码用于前端弹窗展示)。
+//
+// @Summary 找回密码-发送短信验证码
+// @Tags Web
+// @Accept json
+// @Produce json
+// @Param form body dto.PasswordResetSendSMSForm true "form"
+// @Success 200 {object} dto.PasswordResetSendSMSResponse "成功"
+// @Router /v1/auth/password/reset/sms [post]
+// @Bind form body
+func (ctl *auth) passwordResetSendSMS(ctx fiber.Ctx, form *dto.PasswordResetSendSMSForm) (*dto.PasswordResetSendSMSResponse, error) {
+ phone := strings.TrimSpace(form.Phone)
+ if phone == "" {
+ return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号")
+ }
+ if !rePhone.MatchString(phone) {
+ return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确")
+ }
+
+ passwordResetStore.mu.Lock()
+ defer passwordResetStore.mu.Unlock()
+
+ now := time.Now()
+ item := passwordResetStore.phoneToItem[phone]
+ if item == nil {
+ item = &passwordResetState{}
+ passwordResetStore.phoneToItem[phone] = item
+ }
+
+ if !item.lastSentAt.IsZero() {
+ elapsed := now.Sub(item.lastSentAt)
+ if elapsed < passwordResetSendGap {
+ remain := int((passwordResetSendGap - elapsed).Seconds())
+ if remain < 1 {
+ remain = 1
+ }
+ return nil, errorx.ErrTooManyRequests.WithMsgf("操作太频繁,请 %d 秒后再试", remain)
+ }
+ }
+
+ n, err := rand.Int(rand.Reader, big.NewInt(1000000))
+ if err != nil {
+ return nil, errorx.Wrap(err).WithMsg("验证码生成失败,请稍后再试")
+ }
+ code := fmt.Sprintf("%06d", n.Int64())
+
+ item.code = code
+ item.codeExpire = now.Add(passwordResetCodeTTL)
+ item.lastSentAt = now
+ item.resetToken = ""
+ item.tokenExpire = time.Time{}
+
+ return &dto.PasswordResetSendSMSResponse{
+ NextSendSeconds: int(passwordResetSendGap.Seconds()),
+ Code: code,
+ }, nil
+}
+
+// PasswordResetVerify 找回密码:校验短信验证码。
+//
+// @Summary 找回密码-校验验证码
+// @Tags Web
+// @Accept json
+// @Produce json
+// @Param form body dto.PasswordResetVerifyForm true "form"
+// @Success 200 {object} dto.PasswordResetVerifyResponse "成功"
+// @Router /v1/auth/password/reset/verify [post]
+// @Bind form body
+func (ctl *auth) passwordResetVerify(ctx fiber.Ctx, form *dto.PasswordResetVerifyForm) (*dto.PasswordResetVerifyResponse, error) {
+ phone := strings.TrimSpace(form.Phone)
+ if phone == "" {
+ return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号")
+ }
+ if !rePhone.MatchString(phone) {
+ return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确")
+ }
+ code := strings.TrimSpace(form.Code)
+ if code == "" {
+ return nil, errorx.ErrMissingParameter.WithMsg("请输入验证码")
+ }
+
+ passwordResetStore.mu.Lock()
+ defer passwordResetStore.mu.Unlock()
+
+ now := time.Now()
+ item := passwordResetStore.phoneToItem[phone]
+ if item == nil || item.code == "" || item.codeExpire.IsZero() || now.After(item.codeExpire) {
+ return nil, errorx.ErrPreconditionFailed.WithMsg("验证码已过期,请重新获取")
+ }
+ if item.code != code {
+ return nil, errorx.ErrInvalidParameter.WithMsg("验证码错误,请重新输入")
+ }
+
+ // 创建一次性重置令牌,并清理验证码,避免复用。
+ resetToken := uuid.NewString()
+ item.resetToken = resetToken
+ item.tokenExpire = now.Add(passwordResetTokenTTL)
+ item.code = ""
+ item.codeExpire = time.Time{}
+
+ passwordResetStore.tokenToPhone[resetToken] = phone
+
+ return &dto.PasswordResetVerifyResponse{ResetToken: resetToken}, nil
+}
+
+// PasswordReset 找回密码:重置密码。
+//
+// @Summary 找回密码-重置密码
+// @Tags Web
+// @Accept json
+// @Produce json
+// @Param form body dto.PasswordResetForm true "form"
+// @Success 200 {object} dto.PasswordResetResponse "成功"
+// @Router /v1/auth/password/reset [post]
+// @Bind form body
+func (ctl *auth) passwordReset(ctx fiber.Ctx, form *dto.PasswordResetForm) (*dto.PasswordResetResponse, error) {
+ resetToken := strings.TrimSpace(form.ResetToken)
+ if resetToken == "" {
+ return nil, errorx.ErrMissingParameter.WithMsg("请先完成验证码校验")
+ }
+ if form.Password == "" || form.ConfirmPassword == "" {
+ 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("两次输入的密码不一致,请重新确认")
+ }
+
+ passwordResetStore.mu.Lock()
+ phone, ok := passwordResetStore.tokenToPhone[resetToken]
+ item := passwordResetStore.phoneToItem[phone]
+ now := time.Now()
+ if !ok || phone == "" || item == nil || item.resetToken != resetToken || item.tokenExpire.IsZero() || now.After(item.tokenExpire) {
+ passwordResetStore.mu.Unlock()
+ return nil, errorx.ErrTokenInvalid.WithMsg("重置会话已失效,请重新获取验证码")
+ }
+ // 令牌一次性使用
+ delete(passwordResetStore.tokenToPhone, resetToken)
+ item.resetToken = ""
+ item.tokenExpire = time.Time{}
+ passwordResetStore.mu.Unlock()
+
+ // 当前版本将手机号视为用户名。
+ if _, err := services.User.FindByUsername(ctx, phone); err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errorx.ErrRecordNotFound.WithMsg("该手机号尚未注册")
+ }
+ return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试")
+ }
+
+ if err := services.User.ResetPasswordByUsername(ctx, phone, form.Password); err != nil {
+ return nil, errorx.Wrap(err).WithMsg("重置密码失败,请稍后再试")
+ }
+
+ return &dto.PasswordResetResponse{Ok: true}, nil
+}
+
// Register 用户注册(平台侧,非超级管理员)。
//
// @Summary 用户注册
diff --git a/backend/app/http/web/dto/password_reset.go b/backend/app/http/web/dto/password_reset.go
new file mode 100644
index 0000000..1c0a7e0
--- /dev/null
+++ b/backend/app/http/web/dto/password_reset.go
@@ -0,0 +1,45 @@
+package dto
+
+// PasswordResetSendSMSForm 找回密码:发送短信验证码表单。
+type PasswordResetSendSMSForm struct {
+ // Phone 手机号;当前版本将其作为用户名使用(users.username)。
+ Phone string `json:"phone,omitempty"`
+}
+
+// PasswordResetSendSMSResponse 找回密码:发送短信验证码响应。
+type PasswordResetSendSMSResponse struct {
+ // NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。
+ NextSendSeconds int `json:"nextSendSeconds"`
+ // Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。
+ Code string `json:"code,omitempty"`
+}
+
+// PasswordResetVerifyForm 找回密码:验证码校验表单。
+type PasswordResetVerifyForm struct {
+ // Phone 手机号。
+ Phone string `json:"phone,omitempty"`
+ // Code 短信验证码。
+ Code string `json:"code,omitempty"`
+}
+
+// PasswordResetVerifyResponse 找回密码:验证码校验响应。
+type PasswordResetVerifyResponse struct {
+ // ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。
+ ResetToken string `json:"resetToken"`
+}
+
+// PasswordResetForm 找回密码:重置密码表单。
+type PasswordResetForm struct {
+ // ResetToken 重置令牌;由验证码校验接口返回。
+ ResetToken string `json:"resetToken,omitempty"`
+ // Password 新密码(明文)。
+ Password string `json:"password,omitempty"`
+ // ConfirmPassword 确认新密码;必须与 Password 一致。
+ ConfirmPassword string `json:"confirmPassword,omitempty"`
+}
+
+// PasswordResetResponse 找回密码:重置密码响应。
+type PasswordResetResponse struct {
+ // Ok 是否成功。
+ Ok bool `json:"ok"`
+}
diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go
index c1b2d31..3e51012 100644
--- a/backend/app/http/web/routes.gen.go
+++ b/backend/app/http/web/routes.gen.go
@@ -52,6 +52,21 @@ func (r *Routes) Register(router fiber.Router) {
r.auth.login,
Body[dto.LoginForm]("form"),
))
+ r.log.Debugf("Registering route: Post /v1/auth/password/reset -> auth.passwordReset")
+ router.Post("/v1/auth/password/reset"[len(r.Path()):], DataFunc1(
+ r.auth.passwordReset,
+ Body[dto.PasswordResetForm]("form"),
+ ))
+ r.log.Debugf("Registering route: Post /v1/auth/password/reset/sms -> auth.passwordResetSendSMS")
+ router.Post("/v1/auth/password/reset/sms"[len(r.Path()):], DataFunc1(
+ r.auth.passwordResetSendSMS,
+ Body[dto.PasswordResetSendSMSForm]("form"),
+ ))
+ r.log.Debugf("Registering route: Post /v1/auth/password/reset/verify -> auth.passwordResetVerify")
+ router.Post("/v1/auth/password/reset/verify"[len(r.Path()):], DataFunc1(
+ r.auth.passwordResetVerify,
+ Body[dto.PasswordResetVerifyForm]("form"),
+ ))
r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register")
router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1(
r.auth.register,
diff --git a/backend/app/middlewares/user.go b/backend/app/middlewares/user.go
index 5ebf68c..c5c97a4 100644
--- a/backend/app/middlewares/user.go
+++ b/backend/app/middlewares/user.go
@@ -10,7 +10,7 @@ import (
"github.com/gofiber/fiber/v3"
)
-func shouldSkipUserJWTAuth(path string, method string) bool {
+func shouldSkipUserJWTAuth(path, method string) bool {
// 仅对明确的公开接口放行,避免误伤其它路径。
if method != fiber.MethodPost {
return false
@@ -18,7 +18,7 @@ func shouldSkipUserJWTAuth(path string, method string) bool {
p := strings.TrimSuffix(path, "/")
switch p {
- case "/v1/auth/login", "/v1/auth/register":
+ case "/v1/auth/login", "/v1/auth/register", "/v1/auth/password/reset/sms", "/v1/auth/password/reset/verify", "/v1/auth/password/reset":
return true
default:
return false
diff --git a/backend/app/services/user.go b/backend/app/services/user.go
index 372129f..5e88072 100644
--- a/backend/app/services/user.go
+++ b/backend/app/services/user.go
@@ -16,6 +16,7 @@ import (
"go.ipao.vip/gen"
"go.ipao.vip/gen/field"
"go.ipao.vip/gen/types"
+ "golang.org/x/crypto/bcrypt"
)
// @provider
@@ -84,6 +85,31 @@ func (t *user) Create(ctx context.Context, user *models.User) (*models.User, err
return user, nil
}
+// ResetPasswordByUsername 通过用户名(手机号)重置密码。
+func (t *user) ResetPasswordByUsername(ctx context.Context, username, newPassword string) error {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return errors.New("username is required")
+ }
+ if newPassword == "" {
+ return errors.New("new_password is required")
+ }
+
+ m, err := t.FindByUsername(ctx, username)
+ if err != nil {
+ return err
+ }
+
+ // bcrypt hash,避免直接落明文。
+ bytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+ if err != nil {
+ return errors.Wrap(err, "generate password hash failed")
+ }
+
+ m.Password = string(bytes)
+ return m.Save(ctx)
+}
+
// SetStatus 设置用户状态(超级管理员侧)。
func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserStatus) error {
m, err := t.FindByID(ctx, userID)
diff --git a/backend/docs/docs.go b/backend/docs/docs.go
index 37b23bf..d316d3f 100644
--- a/backend/docs/docs.go
+++ b/backend/docs/docs.go
@@ -3885,6 +3885,105 @@ const docTemplate = `{
}
}
},
+ "/v1/auth/password/reset": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-重置密码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/password/reset/sms": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-发送短信验证码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetSendSMSForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetSendSMSResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/password/reset/verify": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-校验验证码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetVerifyForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetVerifyResponse"
+ }
+ }
+ }
+ }
+ },
"/v1/auth/register": {
"post": {
"consumes": [
@@ -4897,6 +4996,76 @@ const docTemplate = `{
}
}
},
+ "dto.PasswordResetForm": {
+ "type": "object",
+ "properties": {
+ "confirmPassword": {
+ "description": "ConfirmPassword 确认新密码;必须与 Password 一致。",
+ "type": "string"
+ },
+ "password": {
+ "description": "Password 新密码(明文)。",
+ "type": "string"
+ },
+ "resetToken": {
+ "description": "ResetToken 重置令牌;由验证码校验接口返回。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetResponse": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "description": "Ok 是否成功。",
+ "type": "boolean"
+ }
+ }
+ },
+ "dto.PasswordResetSendSMSForm": {
+ "type": "object",
+ "properties": {
+ "phone": {
+ "description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetSendSMSResponse": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。",
+ "type": "string"
+ },
+ "nextSendSeconds": {
+ "description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。",
+ "type": "integer"
+ }
+ }
+ },
+ "dto.PasswordResetVerifyForm": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "Code 短信验证码。",
+ "type": "string"
+ },
+ "phone": {
+ "description": "Phone 手机号。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetVerifyResponse": {
+ "type": "object",
+ "properties": {
+ "resetToken": {
+ "description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。",
+ "type": "string"
+ }
+ }
+ },
"dto.PurchaseContentForm": {
"type": "object",
"properties": {
diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json
index cff9ee3..2b42c17 100644
--- a/backend/docs/swagger.json
+++ b/backend/docs/swagger.json
@@ -3879,6 +3879,105 @@
}
}
},
+ "/v1/auth/password/reset": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-重置密码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/password/reset/sms": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-发送短信验证码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetSendSMSForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetSendSMSResponse"
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/password/reset/verify": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Web"
+ ],
+ "summary": "找回密码-校验验证码",
+ "parameters": [
+ {
+ "description": "form",
+ "name": "form",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetVerifyForm"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/dto.PasswordResetVerifyResponse"
+ }
+ }
+ }
+ }
+ },
"/v1/auth/register": {
"post": {
"consumes": [
@@ -4891,6 +4990,76 @@
}
}
},
+ "dto.PasswordResetForm": {
+ "type": "object",
+ "properties": {
+ "confirmPassword": {
+ "description": "ConfirmPassword 确认新密码;必须与 Password 一致。",
+ "type": "string"
+ },
+ "password": {
+ "description": "Password 新密码(明文)。",
+ "type": "string"
+ },
+ "resetToken": {
+ "description": "ResetToken 重置令牌;由验证码校验接口返回。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetResponse": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "description": "Ok 是否成功。",
+ "type": "boolean"
+ }
+ }
+ },
+ "dto.PasswordResetSendSMSForm": {
+ "type": "object",
+ "properties": {
+ "phone": {
+ "description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetSendSMSResponse": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。",
+ "type": "string"
+ },
+ "nextSendSeconds": {
+ "description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。",
+ "type": "integer"
+ }
+ }
+ },
+ "dto.PasswordResetVerifyForm": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "Code 短信验证码。",
+ "type": "string"
+ },
+ "phone": {
+ "description": "Phone 手机号。",
+ "type": "string"
+ }
+ }
+ },
+ "dto.PasswordResetVerifyResponse": {
+ "type": "object",
+ "properties": {
+ "resetToken": {
+ "description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。",
+ "type": "string"
+ }
+ }
+ },
"dto.PurchaseContentForm": {
"type": "object",
"properties": {
diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml
index 2e3e80e..1ee88fb 100644
--- a/backend/docs/swagger.yaml
+++ b/backend/docs/swagger.yaml
@@ -657,6 +657,54 @@ definitions:
name:
type: string
type: object
+ dto.PasswordResetForm:
+ properties:
+ confirmPassword:
+ description: ConfirmPassword 确认新密码;必须与 Password 一致。
+ type: string
+ password:
+ description: Password 新密码(明文)。
+ type: string
+ resetToken:
+ description: ResetToken 重置令牌;由验证码校验接口返回。
+ type: string
+ type: object
+ dto.PasswordResetResponse:
+ properties:
+ ok:
+ description: Ok 是否成功。
+ type: boolean
+ type: object
+ dto.PasswordResetSendSMSForm:
+ properties:
+ phone:
+ description: Phone 手机号;当前版本将其作为用户名使用(users.username)。
+ type: string
+ type: object
+ dto.PasswordResetSendSMSResponse:
+ properties:
+ code:
+ description: Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。
+ type: string
+ nextSendSeconds:
+ description: NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。
+ type: integer
+ type: object
+ dto.PasswordResetVerifyForm:
+ properties:
+ code:
+ description: Code 短信验证码。
+ type: string
+ phone:
+ description: Phone 手机号。
+ type: string
+ type: object
+ dto.PasswordResetVerifyResponse:
+ properties:
+ resetToken:
+ description: ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。
+ type: string
+ type: object
dto.PurchaseContentForm:
properties:
idempotency_key:
@@ -4223,6 +4271,69 @@ paths:
summary: 用户登录
tags:
- Web
+ /v1/auth/password/reset:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: form
+ in: body
+ name: form
+ required: true
+ schema:
+ $ref: '#/definitions/dto.PasswordResetForm'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/dto.PasswordResetResponse'
+ summary: 找回密码-重置密码
+ tags:
+ - Web
+ /v1/auth/password/reset/sms:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: form
+ in: body
+ name: form
+ required: true
+ schema:
+ $ref: '#/definitions/dto.PasswordResetSendSMSForm'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/dto.PasswordResetSendSMSResponse'
+ summary: 找回密码-发送短信验证码
+ tags:
+ - Web
+ /v1/auth/password/reset/verify:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: form
+ in: body
+ name: form
+ required: true
+ schema:
+ $ref: '#/definitions/dto.PasswordResetVerifyForm'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/dto.PasswordResetVerifyResponse'
+ summary: 找回密码-校验验证码
+ tags:
+ - Web
/v1/auth/register:
post:
consumes:
diff --git a/frontend/portal/src/App.vue b/frontend/portal/src/App.vue
index f2f1b9f..d20e4ad 100644
--- a/frontend/portal/src/App.vue
+++ b/frontend/portal/src/App.vue
@@ -2,6 +2,7 @@