This commit is contained in:
zjf committed 2026-10-21 18:05:07 +08:00
1 parent 6398f7f904
commit d26683f6d5
4 files changed
+266 -6

No files matched your search

@@ -5,6 +5,7 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth;
import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingSignHelper;
import com.bsd.pjcloud.supermarket.service.SmHotelHuidingOrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,7 +45,7 @@ public class SmHotelHuidingOrderController {
public Map<String, Object> check(@RequestBody String rawBody) {
try {
JSONObject envelope = JSONUtil.parseObj(rawBody);
String partner = inboundAuth.verifyForHuiding(envelope);
String partner = inboundAuth.verifyForHuiding(rawBody);
JSONObject data = parseDataJson(envelope);
return huidingOrderService.check(partner, data);
}
@@ -60,7 +61,7 @@ public class SmHotelHuidingOrderController {
public Map<String, Object> create(@RequestBody String rawBody) {
try {
JSONObject envelope = JSONUtil.parseObj(rawBody);
String partner = inboundAuth.verifyForHuiding(envelope);
String partner = inboundAuth.verifyForHuiding(rawBody);
JSONObject data = parseDataJson(envelope);
return huidingOrderService.create(partner, data);
}
@@ -76,7 +77,7 @@ public class SmHotelHuidingOrderController {
public Map<String, Object> cancel(@RequestBody String rawBody) {
try {
JSONObject envelope = JSONUtil.parseObj(rawBody);
String partner = inboundAuth.verifyForHuiding(envelope);
String partner = inboundAuth.verifyForHuiding(rawBody);
JSONObject data = parseDataJson(envelope);
return huidingOrderService.cancel(partner, data);
}
@@ -106,6 +107,34 @@ public class SmHotelHuidingOrderController {
}
}
@Inner(value = false)
@Operation(summary = "联调:生成慧订签名报文,或解释为何 check 签名错误")
@PostMapping("/debug-sign")
public Map<String, Object> debugSign(@RequestBody String rawBody) {
JSONObject in = JSONUtil.parseObj(rawBody);
if (StrUtil.isNotBlank(in.getStr("secret"))) {
Object dataRaw = in.get("data");
String dataJson;
if (dataRaw instanceof JSONObject jo) {
dataJson = jo.toString();
}
else {
dataJson = in.getStr("data");
}
Map<String, Object> env = HuidingSignHelper.buildEnvelope(
StrUtil.blankToDefault(in.getStr("appId"), in.getStr("orderAppId")),
in.getStr("secret"), dataJson);
Map<String, Object> wrap = new LinkedHashMap<>();
wrap.put("code", 200);
wrap.put("msg", "把 data 整段贴到 /order/check,Body 选 raw JSON");
wrap.put("data", env);
return wrap;
}
Map<String, Object> explain = inboundAuth.explainHuidingSign(rawBody);
explain.put("code", Boolean.TRUE.equals(explain.get("ok")) ? 200 : 500);
return explain;
}
private static JSONObject parseDataJson(JSONObject envelope) {
Object data = envelope.get("data");
if (data instanceof JSONObject jo) {
@@ -9,6 +9,8 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingSignHelper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -41,6 +43,8 @@ public class DownstreamInboundAuth {
private final SmHotelChannelConfigService channelConfigService;
private final ObjectMapper objectMapper;
public SmHotelDownstreamAppEntity verify(String appKey, String timestamp, String nonce, String sign,
String bodyJson) {
assertCommon(appKey, timestamp, nonce, sign);
@@ -119,11 +123,22 @@ public class DownstreamInboundAuth {
}
}
/**
* 慧订订单入站验签(HMAC-SHA256)。优先吃原始 JSON,避免 data 被二次序列化。
*/
public String verifyForHuiding(String rawBody) {
return verifyForHuiding(parseHuidingEnvelope(rawBody), false);
}
/**
* 慧订订单入站验签(HMAC-SHA256)。返回匹配的伙伴编码。
* @param envelope 外层字段:appId/nonce/signType/timeStamp/sign/data
*/
public String verifyForHuiding(Map<String, Object> envelope) {
return verifyForHuiding(envelope, true);
}
private String verifyForHuiding(Map<String, Object> envelope, boolean skipRawHint) {
if (envelope == null || envelope.isEmpty()) {
throw new CheckedException("报文为空");
}
@@ -132,7 +147,6 @@ public class DownstreamInboundAuth {
String signType = StrUtil.trim(str(envelope.get("signType")));
String timeStamp = StrUtil.trim(str(envelope.get("timeStamp")));
String sign = StrUtil.trim(str(envelope.get("sign")));
// data 必须用报文里的字符串原样参与签名;若被解析成对象再 toString,会和对方 HMAC 对不上
String data = huidingDataForSign(envelope);
if (StrUtil.isBlank(appId) || StrUtil.isBlank(nonce) || StrUtil.isBlank(timeStamp) || StrUtil.isBlank(sign)
|| StrUtil.isBlank(data)) {
@@ -187,8 +201,9 @@ public class DownstreamInboundAuth {
}
}
if (usedSlot == null) {
log.warn("|慧订入站|签名错误|partner={}|appId={}|dataLen={}|", partner, appId, data.length());
throw new CheckedException("签名错误(请确认对方 HMAC 密钥是 order-app-secret,data 用 JSON 字符串原样参与签名)");
log.warn("|慧订入站|签名错误|partner={}|appId={}|dataLen={}|skipRaw={}|slots={}|", partner, appId,
data.length(), skipRawHint, slotSummary(matched));
throw new CheckedException("签名错误");
}
Boolean ok = redisTemplate.opsForValue().setIfAbsent(HUIDING_NONCE_KEY + appId + ":" + nonce, "1", 5,
TimeUnit.MINUTES);
@@ -198,6 +213,127 @@ public class DownstreamInboundAuth {
return partner;
}
/**
* Postman 联调用:看服务端实际匹配到哪套密钥、用哪段 data 验签(不回密钥明文)。
*/
public Map<String, Object> explainHuidingSign(String rawBody) {
Map<String, Object> envelope = parseHuidingEnvelope(rawBody);
Map<String, Object> out = new LinkedHashMap<>();
String appId = StrUtil.trim(str(envelope.get("appId")));
String nonce = StrUtil.trim(str(envelope.get("nonce")));
String signType = StrUtil.trim(str(envelope.get("signType")));
String timeStamp = StrUtil.trim(str(envelope.get("timeStamp")));
String sign = StrUtil.trim(str(envelope.get("sign")));
String data = huidingDataForSign(envelope);
out.put("appId", appId);
out.put("nonce", nonce);
out.put("timeStamp", timeStamp);
out.put("receivedSign", sign);
out.put("dataType", envelope.get("data") == null ? null : envelope.get("data").getClass().getSimpleName());
out.put("dataLen", data == null ? 0 : data.length());
out.put("dataPreview", data == null ? null : StrUtil.maxLength(data, 180));
HotelDownstreamProperties matched = null;
String partner = null;
for (String code : channelConfigService.listEnabledDownstreamPartners()) {
HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code);
if (p == null || !SmHotelDownstreamProtocol.isHuiding(p.getProtocol())) {
continue;
}
String cfgAppId = StrUtil.blankToDefault(StrUtil.trim(p.getOrderAppId()), StrUtil.trim(p.getAppKey()));
if (appId != null && appId.equals(cfgAppId)) {
matched = p;
partner = code;
break;
}
}
out.put("partner", partner);
out.put("appIdMatched", matched != null);
if (matched == null) {
out.put("hint", "appId 没匹配到启用的慧订下游配置,检查渠道对接配置是否启用、protocol=huiding、order-app-id 是否一致");
return out;
}
out.put("secretSlots", slotSummary(matched));
Map<String, Object> signMap = new LinkedHashMap<>();
signMap.put("appId", appId);
signMap.put("data", data);
signMap.put("nonce", nonce);
signMap.put("signType", StrUtil.blankToDefault(signType, HuidingSignHelper.SIGN_TYPE_HMAC));
signMap.put("timeStamp", timeStamp);
String matchedSlot = null;
Map<String, String> expectBySlot = new LinkedHashMap<>();
for (String[] slot : huidingHmacSecrets(matched)) {
if (StrUtil.isBlank(slot[1])) {
continue;
}
String expect = HuidingSignHelper.sign(signMap, slot[1]);
expectBySlot.put(slot[0], expect);
if (expect.equalsIgnoreCase(sign)) {
matchedSlot = slot[0];
}
}
out.put("expectBySlot", expectBySlot);
out.put("matchedSlot", matchedSlot);
out.put("ok", matchedSlot != null);
if (matchedSlot == null) {
out.put("hint", "服务端这几把密钥都对不上你带来的 sign。常见原因:1) 渠道配置未保存/未重启,实际密钥不是 YAML 里那串;"
+ "2) Postman 把 data 从字符串改成了对象。用 /debug-sign 生成后原样粘贴,Body 选 raw JSON。");
}
return out;
}
private Map<String, Object> parseHuidingEnvelope(String rawBody) {
if (StrUtil.isBlank(rawBody)) {
throw new CheckedException("报文为空");
}
Map<String, Object> envelope = new LinkedHashMap<>();
try {
JsonNode root = objectMapper.readTree(rawBody);
envelope.put("appId", textOrNull(root.get("appId")));
envelope.put("nonce", textOrNull(root.get("nonce")));
envelope.put("signType", textOrNull(root.get("signType")));
envelope.put("timeStamp", textOrNull(root.get("timeStamp")));
envelope.put("sign", textOrNull(root.get("sign")));
JsonNode dataNode = root.get("data");
if (dataNode == null || dataNode.isNull()) {
envelope.put("data", null);
}
else if (dataNode.isTextual()) {
envelope.put("data", dataNode.asText());
}
else {
envelope.put("data", objectMapper.writeValueAsString(dataNode));
}
return envelope;
}
catch (CheckedException e) {
throw e;
}
catch (Exception e) {
throw new CheckedException("报文不是合法 JSON");
}
}
private static String textOrNull(JsonNode n) {
if (n == null || n.isNull()) {
return null;
}
if (n.isTextual()) {
return n.asText();
}
return n.asText();
}
private static Map<String, Object> slotSummary(HotelDownstreamProperties p) {
Map<String, Object> m = new LinkedHashMap<>();
for (String[] slot : huidingHmacSecrets(p)) {
Map<String, Object> one = new LinkedHashMap<>(2);
one.put("configured", StrUtil.isNotBlank(slot[1]));
one.put("length", slot[1] == null ? 0 : slot[1].length());
m.put(slot[0], one);
}
return m;
}
private static String huidingDataForSign(Map<String, Object> envelope) {
Object raw = envelope.get("data");
if (raw == null) {
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.supermarket.integration.downstream.huiding;
import cn.hutool.json.JSONUtil;
import java.util.Map;
/**
* 本地生成慧订订单入站报文,给 Postman 用。
* IDEA:打开本类 → 改下面三个常量 → 右键 Run main。
*/
public final class HuidingOrderSignMain {
/** 渠道 YAML 的 order-app-id */
private static final String APP_ID = "hd58cd9d5ba87b0e04";
/** 渠道 YAML 的 order-app-secret(只本地算签) */
private static final String SECRET = "f0b66069c04c5b140a8692189513e0a8243bff5d3082133a";
/**
* check 的 data 业务 JSON(必须是一整段字符串,和 Postman 里 data 字段一致)。
* create / cancel 换成对应业务 JSON 即可。
*/
private static final String DATA_JSON = "{"
+ "\"checkinDate\":\"2026-10-21\","
+ "\"checkoutDate\":\"2026-10-22\","
+ "\"hotelId\":\"FZA64210066\","
+ "\"productId\":\"15465800502898_-1_77159499\","
+ "\"quantity\":1,"
+ "\"roomTypeId\":\"77159499\","
+ "\"totalPrice\":87.56"
+ "}";
private HuidingOrderSignMain() {
}
public static void main(String[] args) {
Map<String, Object> body = HuidingSignHelper.buildEnvelope(APP_ID, SECRET, DATA_JSON);
System.out.println("===== 复制下面整段到 Postman Body(raw JSON),不要改任何字段 =====");
System.out.println(JSONUtil.toJsonStr(body));
System.out.println("===== 单字段 =====");
System.out.println("appId = " + body.get("appId"));
System.out.println("nonce = " + body.get("nonce"));
System.out.println("signType = " + body.get("signType"));
System.out.println("timeStamp= " + body.get("timeStamp"));
System.out.println("sign = " + body.get("sign"));
System.out.println("POST https://www.zianxin.com/api/supermarket/third/party/huiding/order/check");
}
}
@@ -5,7 +5,11 @@ import cn.hutool.core.util.StrUtil;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -15,9 +19,52 @@ public final class HuidingSignHelper {
public static final String SIGN_TYPE_HMAC = "HMAC-SHA256";
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String NONCE_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private HuidingSignHelper() {
}
/**
* 生成慧订入站整包(check/create/cancel 外层),可直接贴进 Postman。
* @param appId order-app-id
* @param secret order-app-secret(只参与本地算签,不会写进返回字段)
* @param dataJson 业务 JSON 字符串,原样参与签名
*/
public static Map<String, Object> buildEnvelope(String appId, String secret, String dataJson) {
return buildEnvelope(appId, secret, dataJson, null, null);
}
public static Map<String, Object> buildEnvelope(String appId, String secret, String dataJson, String nonce,
String timeStamp) {
if (StrUtil.hasBlank(appId, secret, dataJson)) {
throw new IllegalArgumentException("appId / secret / data 不能为空");
}
String n = StrUtil.blankToDefault(StrUtil.trim(nonce), randomNonce());
String ts = StrUtil.blankToDefault(StrUtil.trim(timeStamp), LocalDateTime.now().format(TS));
String data = dataJson.trim();
Map<String, Object> signMap = new LinkedHashMap<>();
signMap.put("appId", appId.trim());
signMap.put("data", data);
signMap.put("nonce", n);
signMap.put("signType", SIGN_TYPE_HMAC);
signMap.put("timeStamp", ts);
String sign = sign(signMap, secret.trim());
Map<String, Object> out = new LinkedHashMap<>(signMap);
out.put("sign", sign);
return out;
}
public static String randomNonce() {
SecureRandom random = new SecureRandom();
char[] chars = new char[32];
for (int i = 0; i < chars.length; i++) {
chars[i] = NONCE_CHARS.charAt(random.nextInt(NONCE_CHARS.length()));
}
return new String(chars);
}
/**
* @param data 参与签名的字段(不含 sign;空值跳过);最后拼接 secret=密钥
*/