Browse Source

fix(聚合支付): 响应对齐线上实测,切生产网关

- base-url 改为 https://pay.huianrong.com/gateway(生产)
- 响应先判 code 再验签,上游业务错误不再被报成「验签失败」
- 响应验签排除 channelRetMsg(按 2026-10-21 真实 JSAPI 下单响应实测)
- 渠道用户 ID 跳转固定 ifCode=fuioupay,忽略调用方传值与商户配置
alphaH 1 week ago
parent
commit
f460384b95

+ 5 - 5
docs/superpowers/specs/2026-10-20-unified-pay-channel-design.md

@@ -10,7 +10,7 @@
 
 汇安融「统一支付平台」(下称「统一支付」)是汇安融的另一条收单产品线,与现有的「汇安融通道」(支付宝收单,`/payment/har`)**并列而非替代**:
 
-- 同一家上游,但接口体系不同:MD5 签名、`t-cube.huianrong.com/gateway`、16 种 `wayCode`、覆盖微信/支付宝/云闪付。
+- 同一家上游,但接口体系不同:MD5 签名、`pay.huianrong.com/gateway`、16 种 `wayCode`、覆盖微信/支付宝/云闪付。
 - 现有 HAR 通道是支付宝单产品;统一支付是聚合收单,商户配置、订单、退款、回调都是独立一套。
 - 用户明确要求:**新开一个菜单**,这是另外的产品。
 
@@ -92,7 +92,7 @@
 | 退款查询 | POST | `/har-pay/api/refund/query` | **仅 SDK** `RefundOrderQueryRequest`(文档未写) |
 | 获取渠道用户 ID | GET | `/har-pay/api/channelUserId/jump` | 文档 §五(浏览器 302) |
 
-`base-url` 默认 `https://t-cube.huianrong.com/gateway`。
+`base-url` 默认 `https://pay.huianrong.com/gateway`。
 
 ### 4.2 请求与签名
 
@@ -366,7 +366,7 @@ module/payment/unified/
 
 | 属性 | 默认值 | 说明 |
 |---|---|---|
-| `base-url` | `https://t-cube.huianrong.com/gateway` | 上游网关 |
+| `base-url` | `https://pay.huianrong.com/gateway` | 上游网关 |
 | `notify-base-url` | `https://qcsj88888.com/api/v1` | 我们给上游的回调基址(含 context-path)。与 HAR 通道通知基址一致(`HarOrderService` 固定拼 `https://qcsj88888.com/api/v1/payment/notify/har`) |
 | `connect-timeout-ms` | 3000 | |
 | `read-timeout-ms` | 10000 | |
@@ -900,8 +900,8 @@ POST /payment/notify/unified/{order|refund}   (免登录)
 1. **先跑迁移**:`db/migration/V1.9__create_pay_unified_tables.sql`(`CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`,可重复执行)。
 2. **再跑菜单**:`db/unified-menu.sql`。执行前先跑脚本头的只读核对:确认 `route_name='payment-unified'` 不存在、role 1/2 存在。
 3. **配置**(按需,改环境变量,不改 `application.yml`):`UNIFIED_PAY_BASE_URL`、`UNIFIED_PAY_NOTIFY_BASE_URL` 等;默认值已写在代码里。
-   - **联调边界**:`t-cube.huianrong.com/gateway` 是用户提供的上游(测试页带测试参数)。凭据只在用户手里,**仓库不保存**;`pay-h5-test.html` 含 `APP_SECRET`,**不得复制进仓库**。builder 不需要上游访问权限:单测用自造向量 + 桩传输;真实联调由用户按第 8 步执行。联调脚本(若写)放 `logs/`,不入库。
-   - **联调时的回调地址**:`UNIFIED_PAY_NOTIFY_BASE_URL` 必须指向**本次联调实例自己可达的地址**,不要指向生产域名(`qcsj88888.com`)。否则上游会把测试单的通知打到生产实例,生产侧只能按「未知单号」记 ERROR + 返回 SUCCESS。上游网关是测试还是生产环境尚未确认(§14 #17);若是生产,验收的 1 元是真金。
+   - **联调边界**:`pay.huianrong.com/gateway` 是生产上游。凭据只在用户手里,**仓库不保存**;`pay-h5-test.html` 含 `APP_SECRET`,**不得复制进仓库**。builder 不需要上游访问权限:单测用自造向量 + 桩传输;真实联调由用户按第 8 步执行。联调脚本(若写)放 `logs/`,不入库。
+   - **联调时的回调地址**:`UNIFIED_PAY_NOTIFY_BASE_URL` 必须指向**本次联调实例自己可达的地址**,不要指向生产域名(`qcsj88888.com`)。否则上游会把测试单的通知打到生产实例,生产侧只能按「未知单号」记 ERROR + 返回 SUCCESS。上游网关是生产环境,验收的 1 元是真金。
 4. **构建**:后端 jar + 前端 dist(命令见项目既有构建说明;`mvn` 用绝对路径,不加 `-q`)。
 5. **换 jar/dist**。
 6. **超管录入商户配置**:`mch_no`、`app_id`、`app_secret`、`channel_user_if_code`。

+ 15 - 11
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedClient.java

@@ -41,6 +41,8 @@ public class UnifiedClient {
     private static final String PATH_REFUND_ORDER = "/har-pay/api/refund/refundOrder";
     private static final String PATH_QUERY_REFUND = "/har-pay/api/refund/query";
     private static final String PATH_CHANNEL_USER_JUMP = "/har-pay/api/channelUserId/jump";
+    /** 渠道用户 ID 跳转固定走服务商模式(业务裁定,不读商户配置、不接受调用方覆盖)。 */
+    private static final String CHANNEL_USER_IF_CODE = "fuioupay";
 
     private static final DateTimeFormatter COMPACT_14 = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
 
@@ -86,7 +88,7 @@ public class UnifiedClient {
      * <p>签名对<b>编码前</b>的值计算;query 值按 UTF-8 URL 编码;
      * {@code appSecret} 只参与签名,绝不出现在 URL 里。
      */
-    public String buildChannelUserJumpUrl(UnifiedMerchantEntity cfg, String ifCode,
+    public String buildChannelUserJumpUrl(UnifiedMerchantEntity cfg,
                                           String redirectUrl, String extParam) {
         Map<String, Object> params = new LinkedHashMap<>();
         params.put("version", VERSION);
@@ -94,7 +96,7 @@ public class UnifiedClient {
         params.put("reqTime", reqTime(props, OffsetDateTime.now()));
         putIfPresent(params, "mchNo", cfg.getMchNo());
         putIfPresent(params, "appId", cfg.getAppId());
-        putIfPresent(params, "ifCode", ifCode);
+        params.put("ifCode", CHANNEL_USER_IF_CODE);
         putIfPresent(params, "redirectUrl", redirectUrl);
         putIfPresent(params, "extParam", extParam);
         attachSign(params, cfg);
@@ -208,15 +210,8 @@ public class UnifiedClient {
             throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游响应不是 JSON 对象");
         }
 
-        // data 可能是 JSON 字符串:必须先解包再验签(verifyData 对非对象按空参数集处理)
-        JsonNode data = UnifiedResponseParser.data(root);
-        String sign = textOrNull(root.get("sign"));
-        if (!UnifiedSigner.verifyData(data, sign, cfg.getAppSecret())) {
-            log.error("[统一支付] {} 响应验签失败 mchOrderNo={} mchRefundNo={}",
-                    api, mchOrderNo, mchRefundNo);
-            throw new UnifiedApiException(UnifiedApiException.CODE_SIGN, "上游响应验签失败");
-        }
-
+        // 先判 code 再验签:上游业务错误可以带非空 data 却不带 sign,先验签会把业务错误
+        // 伪装成「响应验签失败」(SDK 的 isSuccess 也是先判 code)。
         JsonNode codeNode = root.get("code");
         if (codeNode == null || codeNode.isNull()) {
             log.error("[统一支付] {} 响应缺少 code mchOrderNo={} mchRefundNo={}",
@@ -230,6 +225,15 @@ public class UnifiedClient {
                     api, mchOrderNo, mchRefundNo, code, UnifiedLogSanitizer.flatten(msg));
             throw new UnifiedApiException(code, msg);
         }
+
+        // data 可能是 JSON 字符串:必须先解包再验签(verifyData 对非对象按空参数集处理)
+        JsonNode data = UnifiedResponseParser.data(root);
+        String sign = textOrNull(root.get("sign"));
+        if (!UnifiedSigner.verifyData(data, sign, cfg.getAppSecret())) {
+            log.error("[统一支付] {} 响应验签失败 mchOrderNo={} mchRefundNo={}",
+                    api, mchOrderNo, mchRefundNo);
+            throw new UnifiedApiException(UnifiedApiException.CODE_SIGN, "上游响应验签失败");
+        }
         return data;
     }
 

+ 13 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedSigner.java

@@ -22,12 +22,20 @@ import java.util.Map;
  * <p>排序取 ASCII 顺序(spec §4.2 的绑定口径)。上游 SDK 用的是
  * {@link String#CASE_INSENSITIVE_ORDER},但对真实字段集两种排序输出逐字相同,
  * {@code UnifiedSignerTest} 的「两序一致」测试锁住该前提;新增字段使两序分叉时会先红。
+ *
+ * <p><b>响应验签排除 {@code channelRetMsg}</b>:2026-10-21 真实 JSAPI 下单响应
+ * (订单 PJ20261021010255,上游 sign {@code E2906A0D…})按「排除该字段」重算与上游
+ * 逐字符一致;把它算进去必失败。上游 SDK 的 {@code checkSign} 并不排除,属于上游
+ * 签名实现与自家 SDK 的偏差 —— 以线上响应为准。
  */
 public final class UnifiedSigner {
 
     /** 不参与签名的字段名。 */
     private static final String SIGN_FIELD = "sign";
 
+    /** 上游响应中不参与签名的字段(实测,见类注释)。 */
+    private static final String UNSIGNED_RESPONSE_FIELD = "channelRetMsg";
+
     private UnifiedSigner() {
     }
 
@@ -51,6 +59,7 @@ public final class UnifiedSigner {
      *
      * <p>{@code data} 为空(null / JSON null / 空对象)且 {@code sign} 为空时跳过验签返回
      * true —— 上游错误响应就是这种形态;其余情况签名不符一律 false,由调用方抛异常。
+     * 参数集按类注释排除 {@code channelRetMsg}。
      *
      * @param data      响应 data 节点,可为 null
      * @param sign      上游给的签名,可为 null
@@ -66,6 +75,10 @@ public final class UnifiedSigner {
         }
         Map<String, String> params = new LinkedHashMap<>();
         data.fields().forEachRemaining(field -> {
+            // 上游签它的时候这个字段还没挂上去(实测口径,类注释)
+            if (UNSIGNED_RESPONSE_FIELD.equals(field.getKey())) {
+                return;
+            }
             JsonNode value = field.getValue();
             if (value == null || value.isNull()) {
                 return;

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/unified/config/UnifiedPayProperties.java

@@ -22,7 +22,7 @@ import org.springframework.stereotype.Component;
 public class UnifiedPayProperties {
 
     /** 上游网关。 */
-    private String baseUrl = "https://t-cube.huianrong.com/gateway";
+    private String baseUrl = "https://pay.huianrong.com/gateway";
 
     /** 我们给上游的回调基址(含 context-path),拼路径见 {@link #notifyOrderUrl()}。 */
     private String notifyBaseUrl = "https://qcsj88888.com/api/v1";

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedChannelUserJumpCommand.java

@@ -19,7 +19,7 @@ public class UnifiedChannelUserJumpCommand {
     @NotBlank(message = "redirectUrl 不能为空")
     private String redirectUrl;
 
-    /** 渠道标识;空则用商户配置的 {@code channelUserIfCode}。 */
+    /** 兼容字段:后端已固定 {@code fuioupay},此值被忽略。 */
     private String ifCode;
 
     /** 透传参数,原样带到上游。 */

+ 2 - 4
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedChannelUserService.java

@@ -52,10 +52,8 @@ public class UnifiedChannelUserService {
         UnifiedMerchantEntity cfg = merchantService.resolveForTenant(tenantId, cmd.getMchNo());
         merchantService.requireEnabled(cfg);
 
-        String ifCode = StrUtil.isNotBlank(cmd.getIfCode())
-                ? cmd.getIfCode()
-                : cfg.getChannelUserIfCode();
-        String url = client.buildChannelUserJumpUrl(cfg, ifCode, redirectUrl, cmd.getExtParam());
+        // ifCode 由客户端写死 fuioupay:忽略调用方传值与商户配置(业务裁定)
+        String url = client.buildChannelUserJumpUrl(cfg, redirectUrl, cmd.getExtParam());
         return UnifiedChannelUserJumpVO.of(url);
     }
 

+ 35 - 3
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedClientTest.java

@@ -159,6 +159,35 @@ class UnifiedClientTest {
         assertTrue(e.isBusinessRejection());
     }
 
+    @Test
+    void businessError_withNonEmptyDataAndNoSign_reportsCodeNotSignFailure() {
+        // 先验签的旧实现会把这条报成 CODE_SIGN,把上游真实错误吞掉。
+        transport.response = "{\"code\":5,\"msg\":\"余额不足\",\"data\":{\"errCode\":\"E1\"},\"sign\":null}";
+
+        UnifiedApiException e = assertThrows(UnifiedApiException.class,
+                () -> client().queryOrder(merchant(), "O1"));
+
+        assertEquals(5, e.getCode());
+        assertEquals("余额不足", e.getMessage());
+        assertTrue(e.isBusinessRejection());
+    }
+
+    @Test
+    void successResponse_withChannelRetMsg_verifiesWithoutThatField() {
+        // 真实 JSAPI 下单形状(payData 是 JSON 字符串),签名按排除 channelRetMsg 独立算出。
+        transport.response = "{\"code\":200,\"msg\":\"SUCCESS\",\"data\":{"
+                + "\"channelRetMsg\":{\"accName\":null,\"channelState\":\"WAITING\"},"
+                + "\"errCode\":\"030001\",\"mchOrderNo\":\"PJ1\",\"orderState\":1,"
+                + "\"payData\":\"{\\\"appId\\\":\\\"wx1\\\"}\",\"payDataType\":\"wxapp\","
+                + "\"payOrderId\":\"P9\"},"
+                + "\"sign\":\"1769EB230F98B51DB8182FACC4D59AD2\"}";
+
+        JsonNode data = client().unifiedOrder(merchant(), payload());
+
+        assertEquals("wxapp", data.get("payDataType").asText());
+        assertEquals("{\"appId\":\"wx1\"}", data.get("payData").asText());
+    }
+
     @Test
     void responseSignedWithWrongSecret_throwsSignCode() throws Exception {
         transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false, "other-secret");
@@ -235,7 +264,7 @@ class UnifiedClientTest {
 
     @Test
     void jumpUrl_isSignedOverPreEncodingValuesAndNeverLeaksSecret() throws Exception {
-        String url = client().buildChannelUserJumpUrl(merchant(), "fuioupay",
+        String url = client().buildChannelUserJumpUrl(merchant(),
                 "https://merchant.example.test/cb?a=1&b=2", "order=1");
 
         assertTrue(url.startsWith(BASE + "/har-pay/api/channelUserId/jump?"), url);
@@ -267,7 +296,7 @@ class UnifiedClientTest {
         client.queryOrder(merchant(), "O1");
         assertEquals(BASE + "/har-pay/api/pay/query", transport.lastUrl);
 
-        String jump = client.buildChannelUserJumpUrl(merchant(), "fuioupay", "https://a.test/cb", null);
+        String jump = client.buildChannelUserJumpUrl(merchant(), "https://a.test/cb", null);
         assertTrue(jump.startsWith(BASE + "/har-pay/api/channelUserId/jump?"), jump);
     }
 
@@ -315,10 +344,13 @@ class UnifiedClientTest {
         return MAPPER.writeValueAsString(root);
     }
 
-    /** 复刻 verifyData 的取文本规则:标量 asText,嵌套对象/数组取紧凑 JSON。 */
+    /** 复刻 verifyData 的取文本规则:channelRetMsg 不参与,标量 asText,嵌套对象/数组取紧凑 JSON。 */
     private static Map<String, Object> flatten(JsonNode data) {
         Map<String, Object> fields = new LinkedHashMap<>();
         data.fields().forEachRemaining(e -> {
+            if ("channelRetMsg".equals(e.getKey())) {
+                return;
+            }
             JsonNode value = e.getValue();
             if (value == null || value.isNull()) {
                 return;

+ 14 - 3
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedSignerTest.java

@@ -72,15 +72,26 @@ class UnifiedSignerTest {
     }
 
     @Test
-    void responseData_nestedObjectUsesCompactJson() throws Exception {
+    void responseData_channelRetMsgIsExcludedFromSignature() throws Exception {
+        // 上游 2026-10-21 真实 JSAPI 下单响应(PJ20261021010255)实测口径:
+        // channelRetMsg 不参与签名。向量按 state=2&key=secret123 独立算出。
         JsonNode data = MAPPER.readTree(
                 "{\"state\":\"2\",\"channelRetMsg\":{\"channelErrCode\":\"E1\"}}");
-        assertTrue(UnifiedSigner.verifyData(data, "12448BEA270AC8E2D2A8BBA7803B77BC", SECRET));
-        assertFalse(UnifiedSigner.verifyData(data, "00000000000000000000000000000000", SECRET));
+        assertTrue(UnifiedSigner.verifyData(data, "DBA3A42C4E451B5BE7DC3BC5C1A55255", SECRET));
+        // 反向:把它算进去的摘要必须拒绝(12448BEA… 是旧口径的独立向量)
+        assertFalse(UnifiedSigner.verifyData(data, "12448BEA270AC8E2D2A8BBA7803B77BC", SECRET));
         assertFalse(UnifiedSigner.verifyData(data, null, SECRET));
         assertFalse(UnifiedSigner.verifyData(data, "", SECRET));
     }
 
+    @Test
+    void responseData_otherNestedObjectUsesCompactJson() throws Exception {
+        // 排除只针对 channelRetMsg;其他嵌套对象仍按紧凑 JSON 参与签名。
+        JsonNode data = MAPPER.readTree("{\"state\":\"2\",\"extraInfo\":{\"a\":\"b\"}}");
+        assertTrue(UnifiedSigner.verifyData(data, "56CDE594E051ECA251AD239C373A4C8F", SECRET));
+        assertFalse(UnifiedSigner.verifyData(data, "DBA3A42C4E451B5BE7DC3BC5C1A55255", SECRET));
+    }
+
     @Test
     void emptyData_withBlankSign_passes() throws Exception {
         assertTrue(UnifiedSigner.verifyData(null, null, SECRET));

+ 1 - 1
java/src/test/java/com/payment/platform/module/payment/unified/config/UnifiedPayPropertiesTest.java

@@ -18,7 +18,7 @@ class UnifiedPayPropertiesTest {
     @Test
     void 全部十三个默认值与规格一致() {
         UnifiedPayProperties p = new UnifiedPayProperties();
-        assertEquals("https://t-cube.huianrong.com/gateway", p.getBaseUrl());
+        assertEquals("https://pay.huianrong.com/gateway", p.getBaseUrl());
         assertEquals("https://qcsj88888.com/api/v1", p.getNotifyBaseUrl());
         assertEquals(3000, p.getConnectTimeoutMs());
         assertEquals(10000, p.getReadTimeoutMs());

+ 10 - 7
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedChannelUserServiceTest.java

@@ -147,24 +147,27 @@ class UnifiedChannelUserServiceTest {
     // ────────────────────────────── ifCode 来源 ──────────────────────────────
 
     @Test
-    void blankIfCode_usesMerchantConfig() {
-        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
+    void ifCode_isAlwaysFuioupay_evenWhenCallerAndConfigSayOtherwise() {
+        // 两向对照:调用方传 AUTO、商户配置是 wxpay,出站仍必须是写死的 fuioupay
+        UnifiedMerchantEntity cfg = merchant(11L, 2L, "M001", "0");
+        cfg.setChannelUserIfCode("wxpay");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(cfg));
 
-        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "  "));
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "AUTO"));
 
         Map<String, String> query = params(vo.getUrl());
-        assertEquals("fuioupay", query.get("ifCode"), "ifCode 空时应回落到 cfg.channelUserIfCode");
+        assertEquals("fuioupay", query.get("ifCode"), "ifCode 必须由后端写死");
         assertEquals("M001", query.get("mchNo"));
         assertTrue(query.containsKey("sign"), "URL 必须带签名,读数: " + vo.getUrl());
     }
 
     @Test
-    void explicitIfCode_winsOverMerchantConfig() {
+    void blankIfCode_alsoGetsFuioupay() {
         when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
 
-        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "wxpay"));
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "  "));
 
-        assertEquals("wxpay", params(vo.getUrl()).get("ifCode"));
+        assertEquals("fuioupay", params(vo.getUrl()).get("ifCode"));
     }
 
     // ────────────────────────────── URL 内容 ──────────────────────────────