feat: add send phone code

This commit is contained in:
2025-12-20 13:37:20 +08:00
parent 12aa7a404a
commit 3f716b669c
5 changed files with 164 additions and 22 deletions

View File

@@ -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
}