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