This commit is contained in:
Mrx
2026-02-28 11:35:55 +08:00
parent 5eeb6888e6
commit 1ea5ff1d88
3 changed files with 99 additions and 26 deletions

View File

@@ -23,8 +23,25 @@ func SendSmsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
result.ParamValidateErrorResult(r, w, err)
return
}
// 获取客户端真实 IP
clientIP := getClientIP(r)
l := auth.NewSendSmsLogic(r.Context(), svcCtx)
err := l.SendSms(&req)
err := l.SendSms(&req, clientIP)
result.HttpResult(r, w, nil, err)
}
}
// getClientIP 获取客户端真实 IP
func getClientIP(r *http.Request) string {
// 优先从 X-Forwarded-For 获取
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For 可能包含多个 IP取第一个
return xff
}
// 其次从 X-Real-IP 获取
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// 最后使用 RemoteAddr
return r.RemoteAddr
}

View File

@@ -2,11 +2,11 @@ package auth
import (
"context"
"fmt"
"math/rand"
"qnc-server/common/xerr"
"qnc-server/pkg/captcha"
"qnc-server/pkg/lzkit/crypto"
"fmt"
"math/rand"
"time"
"github.com/pkg/errors"
@@ -35,24 +35,58 @@ func NewSendSmsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendSmsLo
}
}
func (l *SendSmsLogic) SendSms(req *types.SendSmsReq) error {
// 1. 图形验证码校验
cfg := l.svcCtx.Config.Captcha
if err := captcha.Verify(captcha.Config{
AccessKeyID: cfg.AccessKeyID,
AccessKeySecret: cfg.AccessKeySecret,
EndpointURL: cfg.EndpointURL,
SceneID: cfg.SceneID,
}, req.CaptchaVerifyParam); err != nil {
return err
}
func (l *SendSmsLogic) SendSms(req *types.SendSmsReq, clientIP string) error {
secretKey := l.svcCtx.Config.Encrypt.SecretKey
encryptedMobile, err := crypto.EncryptMobile(req.Mobile, secretKey)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 加密手机号失败: %v", err)
}
// 检查手机号是否在一分钟内已发送过验证码
// 1. 滑块验证码校验(可选)
cfg := l.svcCtx.Config.Captcha
captchaResult := captcha.VerifyOptional(captcha.Config{
AccessKeyID: cfg.AccessKeyID,
AccessKeySecret: cfg.AccessKeySecret,
EndpointURL: cfg.EndpointURL,
SceneID: cfg.SceneID,
}, req.CaptchaVerifyParam)
if captchaResult.VerifyErr != nil {
return captchaResult.VerifyErr
}
// 2. 防刷策略
if captchaResult.Skipped {
// 没有滑块验证码,使用更严格的限流策略
// 2.1 IP 限流:同一 IP 每小时最多发送 10 次
ipLimitKey := fmt.Sprintf("ip_limit:%s", clientIP)
ipCount, err := l.svcCtx.Redis.Incr(ipLimitKey)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 读取IP限流缓存失败: %v", err)
}
if ipCount == 1 {
// 第一次访问,设置 1 小时过期
l.svcCtx.Redis.Expire(ipLimitKey, 3600)
}
if ipCount > 10 {
return errors.Wrapf(xerr.NewErrMsg("请求过于频繁,请稍后再试"), "短信发送, IP限流: %s, count: %d", clientIP, ipCount)
}
// 2.2 手机号限流:同一手机号每小时最多发送 5 次(无滑块时更严格)
hourLimitKey := fmt.Sprintf("hour_limit:%s:%s", req.ActionType, encryptedMobile)
hourCount, err := l.svcCtx.Redis.Incr(hourLimitKey)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 读取小时限流缓存失败: %v", err)
}
if hourCount == 1 {
l.svcCtx.Redis.Expire(hourLimitKey, 3600)
}
if hourCount > 5 {
return errors.Wrapf(xerr.NewErrMsg("该手机号请求过于频繁,请稍后再试"), "短信发送, 手机号小时限流: %s, count: %d", encryptedMobile, hourCount)
}
}
// 3. 检查手机号是否在一分钟内已发送过验证码(通用)
limitCodeKey := fmt.Sprintf("limit:%s:%s", req.ActionType, encryptedMobile)
exists, err := l.svcCtx.Redis.Exists(limitCodeKey)
if err != nil {
@@ -60,7 +94,6 @@ func (l *SendSmsLogic) SendSms(req *types.SendSmsReq) error {
}
if exists {
// 如果 Redis 中已经存在标记,说明在 1 分钟内请求过,返回错误
return errors.Wrapf(xerr.NewErrMsg("一分钟内不能重复发送验证码"), "短信发送, 手机号1分钟内重复请求发送验证码: %s", encryptedMobile)
}

View File

@@ -19,15 +19,24 @@ type Config struct {
SceneID string
}
// Verify 验证阿里云验证码
func Verify(cfg Config, captchaVerifyParam string) error {
// VerifyResult 验证结果
type VerifyResult struct {
Verified bool // 是否通过滑块验证
Skipped bool // 是否跳过验证(无滑块参数)
VerifyErr error // 验证错误(如果有)
}
// VerifyOptional 可选验证阿里云验证码
// 当 captchaVerifyParam 为空时返回 Skipped=true由调用方决定后续处理
func VerifyOptional(cfg Config, captchaVerifyParam string) VerifyResult {
// 开发环境可跳过验证
if os.Getenv("ENV") == "development" {
return nil
return VerifyResult{Verified: true, Skipped: false}
}
// 没有滑块验证码参数,返回跳过状态
if captchaVerifyParam == "" {
return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "empty captchaVerifyParam")
return VerifyResult{Skipped: true}
}
clientCfg := &openapi.Config{
@@ -37,7 +46,7 @@ func Verify(cfg Config, captchaVerifyParam string) error {
clientCfg.Endpoint = tea.String(cfg.EndpointURL)
client, err := captcha20230305.NewClient(clientCfg)
if err != nil {
return errors.Wrapf(err, "create aliyun captcha client error")
return VerifyResult{VerifyErr: errors.Wrapf(err, "create aliyun captcha client error")}
}
req := &captcha20230305.VerifyIntelligentCaptchaRequest{
@@ -47,13 +56,27 @@ func Verify(cfg Config, captchaVerifyParam string) error {
resp, err := client.VerifyIntelligentCaptcha(req)
if err != nil {
return errors.Wrapf(err, "verify aliyun captcha error")
return VerifyResult{VerifyErr: errors.Wrapf(err, "verify aliyun captcha error")}
}
if tea.BoolValue(resp.Body.Result.VerifyResult) {
return nil
return VerifyResult{Verified: true}
}
return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "aliyun captcha verify failed: code=%s, msg=%s",
tea.StringValue(resp.Body.Code), tea.StringValue(resp.Body.Message))
return VerifyResult{
VerifyErr: errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "aliyun captcha verify failed: code=%s, msg=%s",
tea.StringValue(resp.Body.Code), tea.StringValue(resp.Body.Message)),
}
}
// Verify 验证阿里云验证码(必须提供验证码参数)
func Verify(cfg Config, captchaVerifyParam string) error {
result := VerifyOptional(cfg, captchaVerifyParam)
if result.VerifyErr != nil {
return result.VerifyErr
}
if result.Skipped {
return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "empty captchaVerifyParam")
}
return nil
}