From 3f716b669c7da343088f7ed4cb14ceed14595c3f Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 20 Dec 2025 13:37:20 +0800 Subject: [PATCH] feat: add send phone code --- backend_v1/app/commands/http/http.go | 2 + backend_v1/app/http/auth.go | 31 ++--- backend_v1/app/services/provider.gen.go | 3 + backend_v1/app/services/users.go | 147 +++++++++++++++++++++++- backend_v1/providers/http/engine.go | 3 +- 5 files changed, 164 insertions(+), 22 deletions(-) diff --git a/backend_v1/app/commands/http/http.go b/backend_v1/app/commands/http/http.go index b539cd7..e5c1072 100644 --- a/backend_v1/app/commands/http/http.go +++ b/backend_v1/app/commands/http/http.go @@ -8,6 +8,7 @@ import ( web "quyun/v2/app/http" "quyun/v2/app/jobs" "quyun/v2/app/middlewares" + "quyun/v2/app/services" _ "quyun/v2/docs" "quyun/v2/providers/ali" "quyun/v2/providers/app" @@ -47,6 +48,7 @@ func Command() atom.Option { ). With( jobs.Provide, + services.Provide, middlewares.Provide, ), ), diff --git a/backend_v1/app/http/auth.go b/backend_v1/app/http/auth.go index ac559a8..51b67e4 100644 --- a/backend_v1/app/http/auth.go +++ b/backend_v1/app/http/auth.go @@ -6,7 +6,6 @@ import ( "github.com/gofiber/fiber/v3" "github.com/pkg/errors" - "gorm.io/gorm" ) // @provider @@ -18,49 +17,43 @@ type auth struct { // // @Summary 手机验证 // @Tags Auth +// @Accept json // @Produce json // @Param form body PhoneValidationForm true "手机号" // @Success 200 {object} any "成功" // @Router /v1/auth/phone [post] // @Bind form body func (ctl *auth) Phone(ctx fiber.Ctx, form *PhoneValidationForm) error { - _, err := services.Users.FindByPhone(ctx, form.Phone) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("手机号未注册,请联系管理员开通") - } - return err - } - // TODO: send sms - return nil + return services.Users.SendPhoneCode(ctx, form.Phone) } type PhoneValidationForm struct { - Phone string `json:"phone,omitempty"` - Code *string `json:"code,omitempty"` + Phone string `json:"phone,omitempty"` // 手机号(必须为已注册用户) + Code *string `json:"code,omitempty"` // 短信验证码(/v1/auth/validate 使用) } type TokenResponse struct { - Token string `json:"token,omitempty"` + Token string `json:"token,omitempty"` // 登录 token(用于 Authorization: Bearer ) } // Validate // // @Summary 手机验证 // @Tags Auth +// @Accept json // @Produce json // @Param body body PhoneValidationForm true "请求体" -// @Success 200 {object} any "成功" +// @Success 200 {object} TokenResponse "成功" // @Router /v1/auth/validate [post] // @Bind body body func (ctl *auth) Validate(ctx fiber.Ctx, body *PhoneValidationForm) (*TokenResponse, error) { - user, err := services.Users.FindByPhone(ctx, body.Phone) - if err != nil { - return nil, errors.New("手机号未注册,请联系管理员开通") + if body.Code == nil { + return nil, errors.New("验证码不能为空") } - if body.Code == nil || *body.Code != "1234" { - return nil, errors.New("验证码错误") + user, err := services.Users.ValidatePhoneCode(ctx, body.Phone, *body.Code) + if err != nil { + return nil, err } // generate token for user diff --git a/backend_v1/app/services/provider.gen.go b/backend_v1/app/services/provider.gen.go index 04f3734..f131058 100755 --- a/backend_v1/app/services/provider.gen.go +++ b/backend_v1/app/services/provider.gen.go @@ -54,6 +54,9 @@ func Provide(opts ...opt.Option) error { } if err := container.Container.Provide(func() (*users, error) { obj := &users{} + if err := obj.Prepare(); err != nil { + return nil, err + } return obj, nil }); err != nil { diff --git a/backend_v1/app/services/users.go b/backend_v1/app/services/users.go index a7bf8ce..ad673b6 100644 --- a/backend_v1/app/services/users.go +++ b/backend_v1/app/services/users.go @@ -2,17 +2,38 @@ package services import ( "context" + "crypto/rand" + "fmt" + "math/big" + "strings" + "sync" + "time" "quyun/v2/app/requests" "quyun/v2/database/models" "github.com/pkg/errors" "github.com/samber/lo" + log "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "gorm.io/gorm" ) // @provider -type users struct{} +type users struct { + mu sync.Mutex `inject:"false"` + + lastSentAtByPhone map[string]time.Time `inject:"false"` + codeByPhone map[string]phoneCodeEntry `inject:"false"` +} + +// prepare +func (m *users) Prepare() error { + m.lastSentAtByPhone = make(map[string]time.Time) + m.codeByPhone = make(map[string]phoneCodeEntry) + + return nil +} // List returns a paginated list of users func (m *users) List( @@ -216,3 +237,127 @@ func (m *users) FindByPhone(ctx context.Context, phone string) (*models.User, er tbl, query := models.UserQuery.QueryContext(ctx) return query.Where(tbl.Phone.Eq(phone)).First() } + +type phoneCodeEntry struct { + code string + expiresAt time.Time +} + +func (m *users) ensurePhoneAuthMaps() { + if m.lastSentAtByPhone == nil { + m.lastSentAtByPhone = make(map[string]time.Time) + } + if m.codeByPhone == nil { + m.codeByPhone = make(map[string]phoneCodeEntry) + } +} + +func (m *users) normalizePhone(phone string) string { + return strings.TrimSpace(phone) +} + +func (m *users) isSendTooFrequent(now time.Time, phone string) bool { + last, ok := m.lastSentAtByPhone[phone] + if !ok { + return false + } + // 前端倒计时 60s;后端用 58s 做保护,避免客户端/服务端时间误差导致“刚到 60s 仍被拒绝”。 + return now.Sub(last) < 58*time.Second +} + +func (m *users) gen4Digits() (string, error) { + // 0000-9999 + n, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + return "", errors.Wrap(err, "failed to generate sms code") + } + return fmt.Sprintf("%04d", n.Int64()), nil +} + +// SendPhoneCode 发送短信验证码(内存限流:同一手机号 58s 内仅允许发送一次;验证码 5 分钟过期)。 +func (m *users) SendPhoneCode(ctx context.Context, phone string) error { + phone = m.normalizePhone(phone) + if phone == "" { + return errors.New("手机号不能为空") + } + + // 前置校验:手机号必须已注册 + _, err := m.FindByPhone(ctx, phone) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("手机号未注册,请联系管理员开通") + } + return errors.Wrap(err, "failed to find user by phone") + } + + now := time.Now() + + m.mu.Lock() + defer m.mu.Unlock() + m.ensurePhoneAuthMaps() + + if m.isSendTooFrequent(now, phone) { + return errors.New("验证码发送过于频繁,请稍后再试") + } + + code, err := m.gen4Digits() + if err != nil { + return err + } + + // 生成/覆盖验证码:同一手机号再次发送时以最新验证码为准 + m.codeByPhone[phone] = phoneCodeEntry{ + code: code, + expiresAt: now.Add(5 * time.Minute), + } + m.lastSentAtByPhone[phone] = now + // log phone and code + log.Infof("SendPhoneCode to %s: code=%s", phone, code) + + // TODO: 这里应调用实际短信服务商发送 code;当前仅做内存发码与校验支撑。 + return nil +} + +// ValidatePhoneCode 校验短信验证码,成功后删除验证码并返回用户信息(用于生成 token)。 +func (m *users) ValidatePhoneCode(ctx context.Context, phone, code string) (*models.User, error) { + phone = m.normalizePhone(phone) + code = strings.TrimSpace(code) + if phone == "" { + return nil, errors.New("手机号不能为空") + } + if code == "" { + return nil, errors.New("验证码不能为空") + } + + // 先确认手机号存在,避免对不存在手机号暴露验证码状态 + user, err := m.FindByPhone(ctx, phone) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("手机号未注册,请联系管理员开通") + } + return nil, errors.Wrap(err, "failed to find user by phone") + } + + now := time.Now() + + m.mu.Lock() + defer m.mu.Unlock() + m.ensurePhoneAuthMaps() + + entry, ok := m.codeByPhone[phone] + if !ok { + return nil, errors.New("验证码已过期或不存在") + } + if now.After(entry.expiresAt) { + delete(m.codeByPhone, phone) + return nil, errors.New("验证码已过期或不存在") + } + if entry.code != code { + return nil, errors.New("验证码错误") + } + + // 验证通过后删除验证码,防止重复验证(防重放)。 + delete(m.codeByPhone, phone) + + return user, nil +} diff --git a/backend_v1/providers/http/engine.go b/backend_v1/providers/http/engine.go index 40e15b6..8a81bd4 100644 --- a/backend_v1/providers/http/engine.go +++ b/backend_v1/providers/http/engine.go @@ -16,7 +16,6 @@ import ( "github.com/gofiber/fiber/v3/middleware/compress" "github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/helmet" - "github.com/gofiber/fiber/v3/middleware/limiter" "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/requestid" @@ -137,7 +136,7 @@ func Provide(opts ...opt.Option) error { })) // rate limit (enable standard headers; adjust Max via config if needed) - engine.Use(limiter.New(limiter.Config{Max: 0})) + // engine.Use(limiter.New(limiter.Config{Max: 0})) // static files (Fiber v3 Static helper moved; enable via filesystem middleware later) // if config.StaticRoute != nil && config.StaticPath != nil { ... }