|
|
@@ -40,6 +40,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
import java.math.RoundingMode;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
import java.time.LocalDateTime;
|
|
|
import java.util.*;
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
@@ -538,9 +540,6 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- @Value("${haha.api.app-secret}")
|
|
|
- private String appSecret;
|
|
|
-
|
|
|
@Override
|
|
|
public boolean validateSign(Map<String, Object> params) {
|
|
|
String receivedSign = extractParam(params, "sign");
|
|
|
@@ -549,61 +548,61 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
- String signContent = buildSignContent(params);
|
|
|
-
|
|
|
- // 诊断模式:同时尝试 appsecret 和 ticket 两种方式,日志中明确标注匹配情况
|
|
|
- // 待确认哪种方式正确后,去掉不需要的分支
|
|
|
- boolean matched = false;
|
|
|
-
|
|
|
- // 方式1:末尾拼接 &appsecret=<appSecret>(哈哈API文档描述的方式)
|
|
|
- if (appSecret != null && !appSecret.isEmpty()) {
|
|
|
- String sign1 = md5(signContent + "&appsecret=" + appSecret);
|
|
|
- if (sign1.equalsIgnoreCase(receivedSign)) {
|
|
|
- log.debug("签名验证通过 (appsecret方式)");
|
|
|
- matched = true;
|
|
|
- }
|
|
|
+ // 获取 ticket(从 /token/get 接口返回)
|
|
|
+ String ticket;
|
|
|
+ try {
|
|
|
+ hahaClient.getAccessToken();
|
|
|
+ ticket = hahaClient.getTicket();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取验签 ticket 失败", e);
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- // 方式2:末尾拼接 &ticket=<ticket>(SDK CallbackUtils 描述的方式)
|
|
|
- if (!matched) {
|
|
|
- String ticket = null;
|
|
|
- try {
|
|
|
- hahaClient.getAccessToken();
|
|
|
- ticket = hahaClient.getTicket();
|
|
|
- } catch (Exception e) {
|
|
|
- log.debug("获取 ticket 失败: {}", e.getMessage());
|
|
|
- }
|
|
|
- if (ticket != null && !ticket.isEmpty()) {
|
|
|
- String sign2 = md5(signContent + "&ticket=" + ticket);
|
|
|
- if (sign2.equalsIgnoreCase(receivedSign)) {
|
|
|
- log.debug("签名验证通过 (ticket方式)");
|
|
|
- matched = true;
|
|
|
- }
|
|
|
- }
|
|
|
+ if (ticket == null || ticket.isEmpty()) {
|
|
|
+ log.error("ticket 为空,无法验证签名");
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- if (!matched) {
|
|
|
- log.warn("签名验证失败(两种方式均不匹配) - 接收签名: {}, 签名内容: {}",
|
|
|
- receivedSign, signContent);
|
|
|
- }
|
|
|
+ // 哈哈平台回调签名规则(官方文档):
|
|
|
+ // 第一步:参数按键排序,值 URL 编码后拼接为 k1=v1&k2=v2&...
|
|
|
+ // 第二步:末尾直接拼接 ticket(无 & 或 = 分隔符)
|
|
|
+ // 第三步:MD5 得到签名
|
|
|
+ String signContent = buildSignContent(params);
|
|
|
+ String signStr = signContent + ticket;
|
|
|
+ String computedSign = md5(signStr);
|
|
|
|
|
|
- return matched;
|
|
|
+ boolean isValid = computedSign.equalsIgnoreCase(receivedSign);
|
|
|
+ if (!isValid) {
|
|
|
+ log.warn("签名验证失败 - 计算签名: {}, 接收签名: {}, 签名原文: {}",
|
|
|
+ computedSign, receivedSign, signStr);
|
|
|
+ } else {
|
|
|
+ log.debug("签名验证通过");
|
|
|
+ }
|
|
|
+ return isValid;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 构建待签名字符串
|
|
|
* 将所有参数(排除 sign, sign_type)按 key 字典序排序,
|
|
|
- * 拼接为 key=value 格式,以 & 连接
|
|
|
+ * 值进行 URL 编码后拼接为 key=value 格式,以 & 连接
|
|
|
*/
|
|
|
private String buildSignContent(Map<String, Object> params) {
|
|
|
return params.entrySet().stream()
|
|
|
.filter(e -> !"sign".equals(e.getKey()) && !"sign_type".equals(e.getKey()))
|
|
|
.filter(e -> e.getValue() != null)
|
|
|
.sorted(Map.Entry.comparingByKey())
|
|
|
- .map(e -> e.getKey() + "=" + e.getValue().toString())
|
|
|
+ .map(e -> e.getKey() + "=" + urlEncode(e.getValue().toString()))
|
|
|
.collect(Collectors.joining("&"));
|
|
|
}
|
|
|
|
|
|
+ private String urlEncode(String value) {
|
|
|
+ try {
|
|
|
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
|
|
|
+ } catch (Exception e) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* MD5 加密(32位小写十六进制)
|
|
|
*/
|