From d26683f6d525e906af01fd4c3ea5a0f3f31c893b Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Wed, 21 Oct 2026 18:05:07 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmHotelHuidingOrderController.java | 35 ++++- .../downstream/DownstreamInboundAuth.java | 142 +++++++++++++++++- .../huiding/HuidingOrderSignMain.java | 48 ++++++ .../downstream/huiding/HuidingSignHelper.java | 47 ++++++ 4 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingOrderSignMain.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java index 9cb5e9a3f..4fa7b4f72 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java @@ -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 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 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 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 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 env = HuidingSignHelper.buildEnvelope( + StrUtil.blankToDefault(in.getStr("appId"), in.getStr("orderAppId")), + in.getStr("secret"), dataJson); + Map wrap = new LinkedHashMap<>(); + wrap.put("code", 200); + wrap.put("msg", "把 data 整段贴到 /order/check,Body 选 raw JSON"); + wrap.put("data", env); + return wrap; + } + Map 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) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java index cc3e90baf..cdac4b377 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java @@ -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 envelope) { + return verifyForHuiding(envelope, true); + } + + private String verifyForHuiding(Map 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 explainHuidingSign(String rawBody) { + Map envelope = parseHuidingEnvelope(rawBody); + Map 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 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 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 parseHuidingEnvelope(String rawBody) { + if (StrUtil.isBlank(rawBody)) { + throw new CheckedException("报文为空"); + } + Map 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 slotSummary(HotelDownstreamProperties p) { + Map m = new LinkedHashMap<>(); + for (String[] slot : huidingHmacSecrets(p)) { + Map 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 envelope) { Object raw = envelope.get("data"); if (raw == null) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingOrderSignMain.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingOrderSignMain.java new file mode 100644 index 000000000..479a62ae3 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingOrderSignMain.java @@ -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 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"); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java index 91c536cf8..ed4d33c97 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java @@ -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 buildEnvelope(String appId, String secret, String dataJson) { + return buildEnvelope(appId, secret, dataJson, null, null); + } + + public static Map 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 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 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=密钥 */