330 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. 存储验证码到Redis5分钟有效期
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,
})
}