Jelajahi Sumber

fix: 回调验签从原始请求体构建签名,修复decode→re-encode编码偏差

问题:Spring @RequestParam 先 URL-decode 参数,再经 Java URLEncoder 重新 encode,
与 PHP 端原始 urlencode 结果不一致,导致签名验证失败。

修复:
- 直接从原始 form-urlencoded body 提取参数,保留 PHP 端原始编码
- validateSign 增加 rawBody 优先路径,旧 Map 方式作为 fallback
- Controller 读取原始请求体传递给 Service 层

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 3 hari lalu
induk
melakukan
a58e83211b

+ 17 - 3
haha-miniapp/src/main/java/com/haha/miniapp/controller/CallbackController.java

@@ -58,10 +58,11 @@ public class CallbackController {
      * @return 响应结果,必须返回 "success" 字符串
      */
     @PostMapping("/message")
-    public String handleMessageCallback(HttpServletRequest request, 
+    public String handleMessageCallback(HttpServletRequest request,
                                          @RequestParam(required = false) Map<String, String> requestParams) {
         try {
             Map<String, Object> params = RequestParseUtil.parseRequestParams(request, requestParams);
+            String rawBody = readRawBodyIfFormUrlEncoded(request);
 
             log.info("收到消息回调通知: {}", JSON.toJSONString(params));
 
@@ -77,7 +78,7 @@ public class CallbackController {
                 return CallbackConstants.HAHA_SUCCESS;
             }
 
-            hahaCallbackService.handleMessage(params);
+            hahaCallbackService.handleMessage(params, rawBody);
 
             return CallbackConstants.HAHA_SUCCESS;
 
@@ -103,10 +104,11 @@ public class CallbackController {
                                        @RequestParam(required = false) Map<String, String> requestParams) {
         try {
             Map<String, Object> params = RequestParseUtil.parseRequestParams(request, requestParams);
+            String rawBody = readRawBodyIfFormUrlEncoded(request);
 
             log.info("收到订单回调通知: {}", JSON.toJSONString(params));
 
-            hahaCallbackService.handleOrderCallback(params);
+            hahaCallbackService.handleOrderCallback(params, rawBody);
 
             return CallbackConstants.HAHA_SUCCESS;
 
@@ -116,4 +118,16 @@ public class CallbackController {
         }
     }
 
+    /**
+     * 读取原始请求体(仅限 application/x-www-form-urlencoded 格式)
+     * 用于签名验证时保留 PHP 端的原始 URL 编码
+     */
+    private String readRawBodyIfFormUrlEncoded(HttpServletRequest request) {
+        String contentType = request.getContentType();
+        if (contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded")) {
+            return RequestParseUtil.readRequestBody(request);
+        }
+        return null;
+    }
+
 }

+ 41 - 5
haha-service/src/main/java/com/haha/service/HahaCallbackService.java

@@ -9,10 +9,21 @@ public interface HahaCallbackService {
 
     /**
      * 处理统一消息回调
-     * 
+     *
      * @param params 回调原始参数
+     * @param rawBody 原始请求体(用于签名验证),可为 null
      */
-    void handleMessage(Map<String, Object> params);
+    void handleMessage(Map<String, Object> params, String rawBody);
+
+    /**
+     * 处理统一消息回调(兼容旧调用)
+     *
+     * @deprecated 使用 {@link #handleMessage(Map, String)} 传递原始请求体
+     */
+    @Deprecated
+    default void handleMessage(Map<String, Object> params) {
+        handleMessage(params, null);
+    }
 
     /**
      * 处理开关门状态通知 (DEVICE_STATUS)
@@ -46,16 +57,41 @@ public interface HahaCallbackService {
 
     /**
      * 处理订单信息回调
-     * 
+     *
      * @param params 订单回调参数
+     * @param rawBody 原始请求体(用于签名验证),可为 null
      */
-    void handleOrderCallback(Map<String, Object> params);
+    void handleOrderCallback(Map<String, Object> params, String rawBody);
+
+    /**
+     * 处理订单信息回调(兼容旧调用)
+     *
+     * @deprecated 使用 {@link #handleOrderCallback(Map, String)} 传递原始请求体
+     */
+    @Deprecated
+    default void handleOrderCallback(Map<String, Object> params) {
+        handleOrderCallback(params, null);
+    }
     
     /**
-     * 验证回调签名
+     * 验证回调签名(从已解析的参数Map构建签名,兼容旧逻辑)
+     *
+     * @deprecated 建议使用 {@link #validateSign(Map, String)} 从原始请求体构建签名
      */
     boolean validateSign(Map<String, Object> params);
 
+    /**
+     * 验证回调签名(推荐方式:从原始请求体构建签名)
+     * 直接从原始 form-urlencoded body 构建签名字符串,保留 PHP 端的原始 URL 编码,
+     * 避免 Spring 的 decode → Java URLEncoder re-encode 往返导致的编码差异。
+     *
+     * @param params  已解析的参数 Map(用于提取 sign、timestamp 等)
+     * @param rawBody 原始请求体字符串(application/x-www-form-urlencoded 格式)
+     */
+    default boolean validateSign(Map<String, Object> params, String rawBody) {
+        return validateSign(params);
+    }
+
     /**
      * 获取订单图像(开门前后图片/视频)
      */

+ 79 - 6
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -108,9 +108,9 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
     private com.haha.service.OrderInventoryService orderInventoryService;
 
     @Override
-    public void handleMessage(Map<String, Object> params) {
+    public void handleMessage(Map<String, Object> params, String rawBody) {
         // 签名验证:确保回调来自哈哈平台
-        if (!validateSign(params)) {
+        if (!validateSign(params, rawBody)) {
             log.warn("消息回调签名验证失败,忽略该通知");
             return;
         }
@@ -552,9 +552,9 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void handleOrderCallback(Map<String, Object> params) {
+    public void handleOrderCallback(Map<String, Object> params, String rawBody) {
         // 签名验证:确保订单回调来自哈哈平台
-        if (!validateSign(params)) {
+        if (!validateSign(params, rawBody)) {
             log.warn("订单回调签名验证失败,忽略该通知");
             return;
         }
@@ -614,6 +614,11 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
 
     @Override
     public boolean validateSign(Map<String, Object> params) {
+        return validateSign(params, null);
+    }
+
+    @Override
+    public boolean validateSign(Map<String, Object> params, String rawBody) {
         String receivedSign = extractParam(params, "sign");
         if (receivedSign == null || receivedSign.isEmpty()) {
             log.warn("签名参数为空,回调来源可能未经验证");
@@ -653,7 +658,14 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
         // 第一步:参数按键排序,值 URL 编码后拼接为 k1=v1&k2=v2&...
         // 第二步:末尾直接拼接 ticket(无 & 或 = 分隔符)
         // 第三步:MD5 得到签名
-        String signContent = buildSignContent(params);
+        String signContent;
+        if (rawBody != null && !rawBody.isEmpty()) {
+            // 优先:从原始请求体构建签名,保留 PHP 端的原始 URL 编码
+            signContent = buildSignContentFromRawBody(rawBody);
+        } else {
+            // fallback:从已解析的参数 Map 构建(保留兼容)
+            signContent = buildSignContent(params);
+        }
         String signStr = signContent + ticket;
         String computedSign = md5(signStr);
 
@@ -661,12 +673,73 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
         if (!isValid) {
             log.warn("签名验证失败 - 计算签名: {}, 接收签名: {}, 签名原文: {}",
                     computedSign, receivedSign, signStr);
+            // 如果 rawBody 方式失败,尝试用旧方式再验一次(可能是 JSON 格式的回调)
+            if (rawBody != null && !rawBody.isEmpty()) {
+                String fallbackSignContent = buildSignContent(params);
+                String fallbackSignStr = fallbackSignContent + ticket;
+                String fallbackSign = md5(fallbackSignStr);
+                if (fallbackSign.equalsIgnoreCase(receivedSign)) {
+                    log.info("签名验证通过(fallback 方式)");
+                    return true;
+                }
+                log.warn("两种签名方式均验证失败 - rawBody方式: {}, fallback方式: {}",
+                        computedSign, fallbackSign);
+            }
         } else {
-            log.debug("签名验证通过");
+            log.debug("签名验证通过(rawBody方式)");
         }
         return isValid;
     }
 
+    /**
+     * 从原始 application/x-www-form-urlencoded 请求体构建待签名字符串
+     * 保留 PHP 端的原始 URL 编码,避免 Spring decode → Java URLEncoder re-encode 的编码差异
+     *
+     * 原始 body 格式: k1=v1&k2=v2&...(值已 URL 编码)
+     * 处理方法:按键名排序后重新拼接,保持值的原始编码不变
+     */
+    private String buildSignContentFromRawBody(String rawBody) {
+        if (rawBody == null || rawBody.isEmpty()) {
+            return "";
+        }
+
+        // 解析原始 body 为 key=value 对,保持值的原始编码
+        List<NameValuePair> pairs = new ArrayList<>();
+        String[] parts = rawBody.split("&");
+        for (String part : parts) {
+            int eqIdx = part.indexOf('=');
+            if (eqIdx > 0) {
+                String key = part.substring(0, eqIdx);
+                String value = part.substring(eqIdx + 1);
+                // 排除 sign 和 sign_type 参数
+                if (!"sign".equals(key) && !"sign_type".equals(key)) {
+                    pairs.add(new NameValuePair(key, value));
+                }
+            }
+        }
+
+        // 按 key 字典序排序
+        pairs.sort(java.util.Comparator.comparing(p -> p.key));
+
+        // 拼接签名原文: k1=v1&k2=v2&...
+        return pairs.stream()
+                .map(p -> p.key + "=" + p.value)
+                .collect(Collectors.joining("&"));
+    }
+
+    /**
+     * 简单的 key-value 对(内部使用)
+     */
+    private static class NameValuePair {
+        final String key;
+        final String value;
+
+        NameValuePair(String key, String value) {
+            this.key = key;
+            this.value = value;
+        }
+    }
+
     /**
      * 构建待签名字符串
      * 将所有参数(排除 sign, sign_type)按 key 字典序排序,