package smsser import ( "context" "fmt" "strconv" "strings" "time" "91porn-server/app/appg" "91porn-server/common" "91porn-server/common/constant" "91porn-server/common/constant/redisconst" "91porn-server/common/log" "91porn-server/common/maths" "91porn-server/common/redis" "91porn-server/common/sms" "91porn-server/common/stderr" v10 "91porn-server/common/v10" "91porn-server/common/ysphone" "91porn-server/models/v/usermod" ) type smsCaptchaLimitRule struct { window time.Duration maxCount int64 } var smsCaptchaLimitRules = []smsCaptchaLimitRule{ {window: time.Minute, maxCount: redisconst.MobileCaptchaMaxCountInMinute}, {window: time.Hour, maxCount: redisconst.MobileCaptchaMaxCountInHour}, {window: 24 * time.Hour, maxCount: redisconst.MobileCaptchaMaxCountInDay}, } func SendSmsCode(ctx context.Context, mobile string, typ int) stderr.Code { mobile = strings.TrimSpace(mobile) mobile = ysphone.FormatPhoneNumber(mobile) if !v10.IsGlobalizationPhoneNumber(mobile) { return stderr.ErrMobileInvalid } //如果发送的验证码 用户手机绑定 则先校验 当前手机号是否被绑定 if typ == 1 { //手机号是否绑定 if user, _ := usermod.FindUserByMobile(mobile); user != nil { return stderr.ErrMobileHasBindByOther } } // 限制同一手机号在 1 分钟/1 小时/24 小时内的发送次数,使用滑动窗口统计。 rateCode := checkAndRecordSMSCaptchaRate(ctx, mobile) if rateCode != stderr.Success { return rateCode } redisKey := redisconst.MobileCaptchaKey(mobile) redisCode, err := appg.Redis.Get(redisKey) if err != nil { log.ErrorX(ctx, "SendCaptcha redis get err", log.Any("mobile", mobile), log.E(err)) return stderr.ErrNetWorkBusy } var code string if redisCode != nil { code = *redisCode } else { code = maths.RandDigits(constant.CaptchaLen) } if err = appg.Redis.Set(redisKey, code, redisconst.MobileCaptchaExpire); err != nil { log.ErrorX(ctx, "SendCaptcha Save Redis error", log.Any("mobile", mobile), log.E(err)) return stderr.ErrNetWorkBusy } common.Go(func() { err = sms.Send(sms.FengNiao, mobile, code) if err != nil { log.ErrorX(ctx, "SendCaptcha send err", log.Any("mobile", mobile), log.Any("Platform", sms.FengNiao), log.E(err)) yunErr := sms.Send(sms.YunPian, mobile, code) if yunErr != nil { log.ErrorX(ctx, "SendCaptcha send err", log.Any("mobile", mobile), log.Any("Platform", sms.YunPian), log.E(err)) } } }) return stderr.Success } func checkAndRecordSMSCaptchaRate(ctx context.Context, mobile string) stderr.Code { rateKey := redisconst.SMSCaptchaPhoneNumberKey(mobile) for retry := 0; retry < 2; retry++ { ret, err := checkAndRecordSMSCaptchaRateOnce(rateKey) if err == nil { return ret } if retry == 0 && isRedisWrongTypeError(err) { _, _ = appg.Redis.Del(rateKey) continue } log.ErrorX(ctx, "checkAndRecordSMSCaptchaRate failed", log.Any("mobile", mobile), log.E(err)) return stderr.ErrNetWorkBusy } return stderr.ErrNetWorkBusy } func checkAndRecordSMSCaptchaRateOnce(rateKey string) (stderr.Code, error) { now := time.Now().Unix() dayWindowSec := int64((24 * time.Hour) / time.Second) // 先清理 24 小时窗口外的数据,避免集合无界增长。 _, err := appg.Redis.ZRemRangeByScore(rateKey, "-inf", strconv.FormatInt(now-dayWindowSec, 10)) if err != nil { return stderr.ErrNetWorkBusy, err } for _, rule := range smsCaptchaLimitRules { windowSec := int64(rule.window / time.Second) start := strconv.FormatInt(now-windowSec+1, 10) end := strconv.FormatInt(now, 10) cnt, countErr := appg.Redis.ZCount(rateKey, start, end) if countErr != nil { return stderr.ErrNetWorkBusy, countErr } if cnt >= rule.maxCount { return stderr.VisitLimit, nil } } member := fmt.Sprintf("%d:%d", now, time.Now().UnixNano()) _, err = appg.Redis.ZAdd(rateKey, redis.Member{Score: float64(now), Member: member}) if err != nil { return stderr.ErrNetWorkBusy, err } _, err = appg.Redis.ExpireKey(rateKey, 25*time.Hour) if err != nil { return stderr.ErrNetWorkBusy, err } return stderr.Success, nil } func isRedisWrongTypeError(err error) bool { return err != nil && strings.Contains(err.Error(), "WRONGTYPE") } func VerifySmsCode(mobile string, code string) stderr.Code { mobile = strings.TrimSpace(mobile) if strings.HasPrefix(mobile, "+86122") { // pass the test mobile log.Info("VerifySmsCode dismiss test mobile", log.Any("mobile", mobile)) return stderr.Success } if !v10.IsGlobalizationPhoneNumber(mobile) { return stderr.ErrMobileInvalid } mobile = ysphone.FormatPhoneNumber(mobile) redisKey := redisconst.MobileCaptchaKey(mobile) redisCode, err := appg.Redis.Get(redisKey) if err != nil { log.Error("VerifySmsCode redis get err", log.Any("mobile", mobile), log.E(err)) return stderr.ErrNetWorkBusy } if redisCode != nil && *redisCode == code { log.Info("VerifySmsCode check correct", log.Any("mobile", mobile), log.Any("code", code), log.Any("redisCode", redisCode)) return stderr.Success } if redisCode == nil { log.Info("VerifySmsCode miss", log.Any("mobile", mobile), log.Any("code", code), log.Any("redisCode", redisCode)) return stderr.ErrCaptcha } log.Info("VerifySmsCode invalid", log.Any("mobile", mobile), log.Any("code", code), log.Any("redisCode", redisCode)) return stderr.ErrCaptchaInvalid }