Przeglądaj źródła

优化代码,添加接口

liuchengsen 2 miesięcy temu
rodzic
commit
c9f2d14ca8

+ 35 - 7
zhijiayun-common/src/main/java/com/xuekairui/common/ValidateUtil.java

@@ -99,23 +99,51 @@ public final class ValidateUtil {
     // ==================== 请求工具方法 ====================
     // ==================== 请求工具方法 ====================
 
 
     /**
     /**
-     * 获取客户端真实IP(支持多级代理:X-Forwarded-For → X-Real-IP → remoteAddr)
-     * <p>X-Forwarded-For 可能包含多个IP(逗号分隔),取第一个为真实IP</p>
+     * 获取客户端真实IP,兼容多种反向代理场景
+     * <p>按优先级依次尝试以下来源,取第一个有效值:</p>
+     * <ol>
+     *   <li>X-Forwarded-For(Nginx / CloudFlare 等通用代理)</li>
+     *   <li>X-Real-IP(Nginx 专用)</li>
+     *   <li>Proxy-Client-IP(Apache HTTP Server)</li>
+     *   <li>WL-Proxy-Client-IP(WebLogic)</li>
+     *   <li>HTTP_CLIENT_IP / HTTP_X_FORWARDED_FOR(CGI 风格)</li>
+     *   <li>request.getRemoteAddr()(直连兜底)</li>
+     * </ol>
+     * <p>X-Forwarded-For 可能包含多个IP(逗号分隔),取第一个为真实IP。</p>
+     * <p>IPv6 回环地址 0:0:0:0:0:0:0:1 / ::1 归一化为 127.0.0.1。</p>
      *
      *
      * @param request HTTP请求
      * @param request HTTP请求
-     * @return 客户端IP地址
+     * @return 客户端IP地址;全部获取失败时返回 "unknown"
      */
      */
     public static String getClientIp(HttpServletRequest request) {
     public static String getClientIp(HttpServletRequest request) {
-        String ip = request.getHeader("X-Forwarded-For");
-        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
-            ip = request.getHeader("X-Real-IP");
+        String ip = null;
+        // 按优先级依次尝试各代理 header
+        String[] headerNames = {
+                "X-Forwarded-For",
+                "X-Real-IP",
+                "Proxy-Client-IP",
+                "WL-Proxy-Client-IP",
+                "HTTP_CLIENT_IP",
+                "HTTP_X_FORWARDED_FOR"
+        };
+        for (String header : headerNames) {
+            ip = request.getHeader(header);
+            if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
+                break;
+            }
         }
         }
+        // 所有 header 均无效,回退到直连地址
         if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
         if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
             ip = request.getRemoteAddr();
             ip = request.getRemoteAddr();
         }
         }
+        // X-Forwarded-For 多级代理链:取第一个(最原始的客户端IP)
         if (ip != null && ip.contains(",")) {
         if (ip != null && ip.contains(",")) {
             ip = ip.split(",")[0].trim();
             ip = ip.split(",")[0].trim();
         }
         }
-        return ip;
+        // IPv6 回环归一化为 IPv4,避免本地开发时 Redis Key 含冒号
+        if ("0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) {
+            ip = "127.0.0.1";
+        }
+        return ip != null ? ip : "unknown";
     }
     }
 }
 }

+ 5 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java

@@ -28,8 +28,11 @@ public class AuthController {
      * POST /api/auth/sms/send
      * POST /api/auth/sms/send
      */
      */
     @PostMapping("/sms/send")
     @PostMapping("/sms/send")
-    public Result<Void> sendCode(@Valid @RequestBody SendCodeRequest request) {
-        authService.sendVerificationCode(request);
+    public Result<Void> sendCode(
+            @Valid @RequestBody SendCodeRequest request,
+            HttpServletRequest httpRequest) {
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        authService.sendVerificationCode(request, ip);
         return Result.success();
         return Result.success();
     }
     }
 
 

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -43,8 +43,8 @@ public class AuthService {
     /**
     /**
      * 发送验证码
      * 发送验证码
      */
      */
-    public void sendVerificationCode(SendCodeRequest request) {
-        verificationCodeService.sendCode(request.getPhone(), request.getScene());
+    public void sendVerificationCode(SendCodeRequest request, String ip) {
+        verificationCodeService.sendCode(request.getPhone(), request.getScene(), ip);
     }
     }
 
 
     /**
     /**

+ 2 - 19
zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

@@ -3,6 +3,7 @@ package com.xuekairui.user.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.entity.OperationAuditLog;
 import com.xuekairui.user.entity.OperationAuditLog;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.mapper.OperationAuditLogMapper;
 import com.xuekairui.user.mapper.OperationAuditLogMapper;
@@ -192,7 +193,7 @@ public class OperationAuditLogService {
                     (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                     (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
             if (attributes != null) {
             if (attributes != null) {
                 HttpServletRequest request = attributes.getRequest();
                 HttpServletRequest request = attributes.getRequest();
-                auditLog.setIpAddress(getClientIp(request));
+                auditLog.setIpAddress(ValidateUtil.getClientIp(request));
                 auditLog.setUserAgent(request.getHeader("User-Agent"));
                 auditLog.setUserAgent(request.getHeader("User-Agent"));
                 auditLog.setRequestUrl(request.getRequestURI());
                 auditLog.setRequestUrl(request.getRequestURI());
                 auditLog.setRequestMethod(request.getMethod());
                 auditLog.setRequestMethod(request.getMethod());
@@ -202,24 +203,6 @@ public class OperationAuditLogService {
         }
         }
     }
     }
 
 
-    /**
-     * 获取客户端IP
-     */
-    private String getClientIp(HttpServletRequest request) {
-        String ip = request.getHeader("X-Forwarded-For");
-        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
-            ip = request.getHeader("X-Real-IP");
-        }
-        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
-            ip = request.getRemoteAddr();
-        }
-        // 多个代理时,第一个IP为真实IP
-        if (ip != null && ip.contains(",")) {
-            ip = ip.split(",")[0].trim();
-        }
-        return ip;
-    }
-
     /**
     /**
      * 对象转JSON
      * 对象转JSON
      */
      */

+ 54 - 13
zhijiayun-user/src/main/java/com/xuekairui/user/service/VerificationCodeService.java

@@ -38,12 +38,20 @@ public class VerificationCodeService {
 
 
     /** Redis Key 前缀 */
     /** Redis Key 前缀 */
     private static final String CODE_PREFIX = "sms:code:";
     private static final String CODE_PREFIX = "sms:code:";
-    private static final String LIMIT_PREFIX = "sms:limit:";
+    private static final String INTERVAL_PREFIX = "sms:interval:";
+    private static final String DAILY_COUNT_PREFIX = "sms:daily:";
+    private static final String IP_HOURLY_PREFIX = "sms:ip:";
 
 
-    /** 发送间隔限制(秒) */
+    /** 最小发送间隔(秒) */
     private static final int SEND_INTERVAL = 60;
     private static final int SEND_INTERVAL = 60;
     /** 每日发送次数限制 */
     /** 每日发送次数限制 */
     private static final int DAILY_LIMIT = 10;
     private static final int DAILY_LIMIT = 10;
+    /** 每日计数窗口(秒)= 24小时 */
+    private static final int DAILY_WINDOW = 24 * 60 * 60;
+    /** 单IP每小时发送次数限制 */
+    private static final int IP_HOURLY_LIMIT = 20;
+    /** IP计数窗口(秒)= 1小时 */
+    private static final int IP_WINDOW = 60 * 60;
 
 
     /**
     /**
      * 生成并发送验证码
      * 生成并发送验证码
@@ -51,8 +59,9 @@ public class VerificationCodeService {
      *
      *
      * @param phone 手机号
      * @param phone 手机号
      * @param scene 场景(仅用于日志记录,不影响存储 key)
      * @param scene 场景(仅用于日志记录,不影响存储 key)
+     * @param ip    客户端IP(用于IP维度限流)
      */
      */
-    public void sendCode(String phone, String scene) {
+    public void sendCode(String phone, String scene, String ip) {
         // 测试模式:使用固定验证码,跳过真实短信发送
         // 测试模式:使用固定验证码,跳过真实短信发送
         log.info("【验证码-测试模式】是否启用 {}", testMode);
         log.info("【验证码-测试模式】是否启用 {}", testMode);
         if (testMode) {
         if (testMode) {
@@ -63,8 +72,8 @@ public class VerificationCodeService {
             return;
             return;
         }
         }
 
 
-        // 检查发送频率限制
-        checkSendLimit(phone);
+        // 检查发送频率限制(1分钟间隔 + 每日次数 + IP每小时次数)
+        checkSendLimit(phone, ip);
 
 
         // 生成验证码
         // 生成验证码
         String code = generateCode();
         String code = generateCode();
@@ -73,10 +82,25 @@ public class VerificationCodeService {
         String codeKey = CODE_PREFIX + phone;
         String codeKey = CODE_PREFIX + phone;
         redisTemplate.opsForValue().set(codeKey, code, expireMinutes, TimeUnit.MINUTES);
         redisTemplate.opsForValue().set(codeKey, code, expireMinutes, TimeUnit.MINUTES);
 
 
-        // 记录发送次数
-        String limitKey = LIMIT_PREFIX + phone;
-        redisTemplate.opsForValue().increment(limitKey);
-        redisTemplate.expire(limitKey, SEND_INTERVAL, TimeUnit.SECONDS);
+        // 设置1分钟间隔锁
+        String intervalKey = INTERVAL_PREFIX + phone;
+        redisTemplate.opsForValue().set(intervalKey, "1", SEND_INTERVAL, TimeUnit.SECONDS);
+
+        // 递增每日发送次数
+        String dailyKey = DAILY_COUNT_PREFIX + phone;
+        Long count = redisTemplate.opsForValue().increment(dailyKey);
+        if (count != null && count == 1) {
+            redisTemplate.expire(dailyKey, DAILY_WINDOW, TimeUnit.SECONDS);
+        }
+
+        // 递增IP每小时发送次数
+        if (ip != null && !ip.isEmpty()) {
+            String ipKey = IP_HOURLY_PREFIX + ip;
+            Long ipCount = redisTemplate.opsForValue().increment(ipKey);
+            if (ipCount != null && ipCount == 1) {
+                redisTemplate.expire(ipKey, IP_WINDOW, TimeUnit.SECONDS);
+            }
+        }
 
 
         // 通过阿里云短信服务发送验证码
         // 通过阿里云短信服务发送验证码
         aliyunSmsClient.sendVerificationCode(phone, code);
         aliyunSmsClient.sendVerificationCode(phone, code);
@@ -124,15 +148,32 @@ public class VerificationCodeService {
     }
     }
 
 
     /**
     /**
-     * 检查发送限制
+     * 检查发送限制:1分钟间隔 + 每日次数上限 + IP每小时次数上限
      */
      */
-    private void checkSendLimit(String phone) {
-        String limitKey = LIMIT_PREFIX + phone;
-        Object count = redisTemplate.opsForValue().get(limitKey);
+    private void checkSendLimit(String phone, String ip) {
+        // 1. 检查1分钟间隔锁
+        String intervalKey = INTERVAL_PREFIX + phone;
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(intervalKey))) {
+            throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
+                    "发送过于频繁,请60秒后再试");
+        }
 
 
+        // 2. 检查每日发送次数
+        String dailyKey = DAILY_COUNT_PREFIX + phone;
+        Object count = redisTemplate.opsForValue().get(dailyKey);
         if (count != null && Integer.parseInt(count.toString()) >= DAILY_LIMIT) {
         if (count != null && Integer.parseInt(count.toString()) >= DAILY_LIMIT) {
             throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
             throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
                     "今日发送次数已达上限");
                     "今日发送次数已达上限");
         }
         }
+
+        // 3. 检查IP每小时发送次数
+        if (ip != null && !ip.isEmpty()) {
+            String ipKey = IP_HOURLY_PREFIX + ip;
+            Object ipCount = redisTemplate.opsForValue().get(ipKey);
+            if (ipCount != null && Integer.parseInt(ipCount.toString()) >= IP_HOURLY_LIMIT) {
+                throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
+                        "该IP发送次数过多,请稍后再试");
+            }
+        }
     }
     }
 }
 }