330 lines
9.4 KiB
Go
330 lines
9.4 KiB
Go
package user
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"image/png"
|
||
"math/rand"
|
||
"regexp"
|
||
"strconv"
|
||
"time"
|
||
|
||
"bindbox-game/configs"
|
||
"bindbox-game/internal/pkg/sms"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
|
||
randomname "github.com/DanPlayer/randomname"
|
||
identicon "github.com/issue9/identicon/v2"
|
||
"github.com/redis/go-redis/v9"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// Redis key patterns
|
||
const (
|
||
smsCodeKey = "sms:code:%s" // 验证码存储
|
||
smsLimitKey = "sms:limit:%s" // 发送频率限制(60秒)
|
||
smsDailyKey = "sms:daily:%s" // 每日发送计数
|
||
smsAttemptKey = "sms:attempt:%s" // 验证尝试次数
|
||
)
|
||
|
||
// 配置常量
|
||
const (
|
||
codeLength = 6 // 验证码长度
|
||
codeExpireSec = 300 // 验证码有效期(秒)
|
||
sendLimitSec = 60 // 发送间隔限制(秒)
|
||
dailyLimit = 10 // 每日发送上限
|
||
attemptLimit = 5 // 验证尝试上限
|
||
testCode = "202511" // 测试验证码,任何手机号都可以使用
|
||
)
|
||
|
||
// SmsLoginInput 短信登录输入参数
|
||
type SmsLoginInput struct {
|
||
Mobile string
|
||
Code string
|
||
InviteCode string
|
||
}
|
||
|
||
// SmsLoginOutput 短信登录输出结果
|
||
type SmsLoginOutput struct {
|
||
User *model.Users
|
||
Token string
|
||
IsNewUser bool
|
||
InviterID int64
|
||
}
|
||
|
||
// 手机号正则
|
||
var mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||
|
||
// SendSmsCode 发送短信验证码
|
||
func (s *service) SendSmsCode(ctx context.Context, mobile string) error {
|
||
// 1. 校验手机号格式
|
||
if !mobileRegex.MatchString(mobile) {
|
||
return errors.New("手机号格式不正确")
|
||
}
|
||
|
||
rdb := getRedisClient()
|
||
|
||
// 2. 检查发送频率限制(60秒内不可重复发送)
|
||
limitKey := fmt.Sprintf(smsLimitKey, mobile)
|
||
ttl, err := rdb.TTL(ctx, limitKey).Result()
|
||
if err == nil && ttl > 0 {
|
||
return fmt.Errorf("请等待 %d 秒后再次发送", int(ttl.Seconds()))
|
||
}
|
||
|
||
// 3. 检查每日发送上限
|
||
dailyKey := fmt.Sprintf(smsDailyKey, mobile)
|
||
count, _ := rdb.Get(ctx, dailyKey).Int()
|
||
if count >= dailyLimit {
|
||
return errors.New("今日发送次数已达上限,请明日再试")
|
||
}
|
||
|
||
// 4. 生成6位验证码
|
||
code := generateCode(codeLength)
|
||
|
||
// 5. 发送短信
|
||
cfg := configs.Get().AliyunSMS
|
||
smsClient, err := sms.NewClient(sms.Config{
|
||
AccessKeyID: cfg.AccessKeyID,
|
||
AccessKeySecret: cfg.AccessKeySecret,
|
||
SignName: cfg.SignName,
|
||
TemplateCode: cfg.TemplateCode,
|
||
})
|
||
if err != nil {
|
||
s.logger.Error("创建短信客户端失败", zap.Error(err))
|
||
return errors.New("短信服务暂不可用,请稍后重试")
|
||
}
|
||
|
||
if err := smsClient.SendCode(mobile, code); err != nil {
|
||
s.logger.Error("发送短信失败", zap.String("mobile", mobile), zap.Error(err))
|
||
return errors.New("短信发送失败,请稍后重试")
|
||
}
|
||
|
||
// 6. 存储验证码到Redis(5分钟有效期)
|
||
codeKey := fmt.Sprintf(smsCodeKey, mobile)
|
||
if err := rdb.Set(ctx, codeKey, code, time.Duration(codeExpireSec)*time.Second).Err(); err != nil {
|
||
s.logger.Error("存储验证码失败", zap.Error(err))
|
||
return errors.New("系统错误,请稍后重试")
|
||
}
|
||
|
||
// 7. 设置发送频率限制(60秒)
|
||
rdb.Set(ctx, limitKey, "1", time.Duration(sendLimitSec)*time.Second)
|
||
|
||
// 8. 增加每日发送计数
|
||
rdb.Incr(ctx, dailyKey)
|
||
// 设置每日计数的过期时间(到当天23:59:59)
|
||
endOfDay := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 23, 59, 59, 0, time.Local)
|
||
rdb.ExpireAt(ctx, dailyKey, endOfDay)
|
||
|
||
// 9. 清除之前的验证尝试计数
|
||
attemptKey := fmt.Sprintf(smsAttemptKey, mobile)
|
||
rdb.Del(ctx, attemptKey)
|
||
|
||
s.logger.Info("短信验证码发送成功", zap.String("mobile", mobile))
|
||
return nil
|
||
}
|
||
|
||
// LoginByCode 通过验证码登录
|
||
func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, error) {
|
||
// 1. 校验手机号格式
|
||
if !mobileRegex.MatchString(in.Mobile) {
|
||
return nil, errors.New("手机号格式不正确")
|
||
}
|
||
|
||
if in.Code == "" {
|
||
return nil, errors.New("验证码不能为空")
|
||
}
|
||
|
||
rdb := getRedisClient()
|
||
|
||
// 2. 检查验证尝试次数
|
||
attemptKey := fmt.Sprintf(smsAttemptKey, in.Mobile)
|
||
attempts, _ := rdb.Get(ctx, attemptKey).Int()
|
||
if attempts >= attemptLimit {
|
||
return nil, errors.New("验证次数过多,请重新获取验证码")
|
||
}
|
||
|
||
// 3. 获取存储的验证码(测试验证码跳过此检查)
|
||
codeKey := fmt.Sprintf(smsCodeKey, in.Mobile)
|
||
storedCode, err := rdb.Get(ctx, codeKey).Result()
|
||
|
||
// 使用测试验证码时跳过Redis检查
|
||
if in.Code == testCode {
|
||
s.logger.Info("使用测试验证码登录", zap.String("mobile", in.Mobile))
|
||
storedCode = testCode // 设置为测试验证码以通过后续比对
|
||
err = nil
|
||
}
|
||
|
||
if err == redis.Nil {
|
||
return nil, errors.New("验证码已过期,请重新获取")
|
||
}
|
||
if err != nil {
|
||
s.logger.Error("获取验证码失败", zap.Error(err))
|
||
return nil, errors.New("系统错误,请稍后重试")
|
||
}
|
||
|
||
// 4. 验证码比对(支持测试验证码)
|
||
if storedCode != in.Code && in.Code != testCode {
|
||
// 增加验证尝试次数
|
||
rdb.Incr(ctx, attemptKey)
|
||
rdb.Expire(ctx, attemptKey, time.Duration(codeExpireSec)*time.Second)
|
||
remaining := attemptLimit - attempts - 1
|
||
if remaining > 0 {
|
||
return nil, fmt.Errorf("验证码错误,还剩 %d 次尝试机会", remaining)
|
||
}
|
||
return nil, errors.New("验证码错误")
|
||
}
|
||
|
||
// 5. 验证成功,删除验证码(防止重放攻击)
|
||
rdb.Del(ctx, codeKey)
|
||
rdb.Del(ctx, attemptKey)
|
||
|
||
// 6. 查找或创建用户
|
||
var user *model.Users
|
||
isNewUser := false
|
||
|
||
err = s.writeDB.Transaction(func(tx *dao.Query) error {
|
||
var txErr error
|
||
|
||
// 通过手机号查找用户
|
||
user, txErr = tx.Users.WithContext(ctx).
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where(tx.Users.Mobile.Eq(in.Mobile)).
|
||
First()
|
||
|
||
if txErr != nil && !errors.Is(txErr, gorm.ErrRecordNotFound) {
|
||
return txErr
|
||
}
|
||
|
||
if user == nil {
|
||
// 用户不存在,创建新用户
|
||
isNewUser = true
|
||
inviteCode := s.generateInviteCode(ctx)
|
||
nickname := randomname.GenerateName()
|
||
|
||
// 生成默认头像
|
||
img := identicon.S2(128).Make([]byte(in.Mobile))
|
||
var buf bytes.Buffer
|
||
_ = png.Encode(&buf, img)
|
||
avatar := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||
|
||
user = &model.Users{
|
||
Nickname: nickname,
|
||
Avatar: avatar,
|
||
Mobile: in.Mobile,
|
||
InviteCode: inviteCode,
|
||
Status: 1,
|
||
}
|
||
|
||
if txErr = tx.Users.WithContext(ctx).Create(user); txErr != nil {
|
||
return txErr
|
||
}
|
||
|
||
s.logger.Info("短信登录创建新用户", zap.Int64("user_id", user.ID), zap.String("mobile", in.Mobile))
|
||
}
|
||
|
||
// 处理邀请码逻辑(仅在真正的首次账户创建时触发,防止重复领奖)
|
||
if in.InviteCode != "" && isNewUser {
|
||
existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(user.ID)).First()
|
||
if existed == nil {
|
||
inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
|
||
if inviter != nil && inviter.ID != user.ID {
|
||
reward := int64(10)
|
||
inv := &model.UserInvites{
|
||
InviterID: inviter.ID,
|
||
InviteeID: user.ID,
|
||
InviteCode: in.InviteCode,
|
||
RewardPoints: reward,
|
||
RewardedAt: time.Now(),
|
||
}
|
||
if txErr = tx.UserInvites.WithContext(ctx).Create(inv); txErr != nil {
|
||
return txErr
|
||
}
|
||
|
||
// 更新被邀请人的 inviter_id
|
||
if user.InviterID == 0 {
|
||
tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(user.ID)).Updates(map[string]any{"inviter_id": inviter.ID})
|
||
}
|
||
|
||
// 为邀请人增加积分
|
||
points, _ := tx.UserPoints.WithContext(ctx).
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where(tx.UserPoints.UserID.Eq(inviter.ID)).
|
||
Where(tx.UserPoints.Kind.Eq("invite")).
|
||
First()
|
||
|
||
if points == nil {
|
||
points = &model.UserPoints{
|
||
UserID: inviter.ID,
|
||
Kind: "invite",
|
||
Points: reward,
|
||
ValidStart: time.Now(),
|
||
}
|
||
do := tx.UserPoints.WithContext(ctx)
|
||
if points.ValidEnd.IsZero() {
|
||
do = do.Omit(tx.UserPoints.ValidEnd)
|
||
}
|
||
if txErr = do.Create(points); txErr != nil {
|
||
return txErr
|
||
}
|
||
} else {
|
||
tx.UserPoints.WithContext(ctx).
|
||
Where(tx.UserPoints.ID.Eq(points.ID)).
|
||
Updates(map[string]any{"points": points.Points + reward})
|
||
}
|
||
|
||
// 记录积分流水
|
||
ledger := &model.UserPointsLedger{
|
||
UserID: inviter.ID,
|
||
Action: "invite_reward",
|
||
Points: reward,
|
||
RefTable: "user_invites",
|
||
RefID: strconv.FormatInt(inv.ID, 10),
|
||
Remark: "invite_reward",
|
||
}
|
||
tx.UserPointsLedger.WithContext(ctx).Create(ledger)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
s.logger.Error("短信登录数据库操作失败", zap.Error(err))
|
||
return nil, errors.New("登录失败,请稍后重试")
|
||
}
|
||
|
||
s.logger.Info("短信登录成功", zap.Int64("user_id", user.ID), zap.String("mobile", in.Mobile), zap.Bool("is_new", isNewUser))
|
||
|
||
return &SmsLoginOutput{
|
||
User: user,
|
||
IsNewUser: isNewUser,
|
||
}, nil
|
||
}
|
||
|
||
// generateCode 生成指定长度的数字验证码
|
||
func generateCode(length int) string {
|
||
rand.Seed(time.Now().UnixNano())
|
||
code := ""
|
||
for i := 0; i < length; i++ {
|
||
code += strconv.Itoa(rand.Intn(10))
|
||
}
|
||
return code
|
||
}
|
||
|
||
// getRedisClient 获取Redis客户端
|
||
func getRedisClient() *redis.Client {
|
||
// 动态导入,避免循环依赖
|
||
return redis.NewClient(&redis.Options{
|
||
Addr: configs.Get().Redis.Addr,
|
||
Password: configs.Get().Redis.Pass,
|
||
DB: configs.Get().Redis.DB,
|
||
})
|
||
}
|