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, }) }