feat: add send phone code
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 <token>)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 { ... }
|
||||
|
||||
Reference in New Issue
Block a user