Explorar el Código

fix(virtual-pay): offerId 改为支付应用ID并按官方 camelCase JSON 串重建 signData 签名

根因: orderInfo 误用服务端 API snake_case 字段, offerId 位置误填道具ID。修正: orderInfo 按 wx.requestVirtualPayment 官方 schema(offerId/buyQuantity/env/currencyType/productId/goodsPrice/outTradeNo/attach), mode 为顶层参数, signData=JSON串, paySig=hmac(appKey, requestVirtualPayment&signData), signature=hmac(sessionKey, signData), 双签名基于同一 JSON 串与前端 JSON.stringify 逐字节一致。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
asus hace 3 meses
padre
commit
7e1cbd599b

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/VirtualPayParamsDTO.java

@@ -12,7 +12,9 @@ import java.util.Map;
 public class VirtualPayParamsDTO {
 
     /**
-     * 订单信息:{mch_id, appid, out_trade_no, total_fee, product_info, attach, mode, offerId, ...}
+     * 订单信息(signData 结构,camelCase,道具直购):
+     * {offerId(支付应用ID), buyQuantity, env, currencyType, productId(道具ID), goodsPrice, outTradeNo, attach}
+     * 前端 JSON.stringify(orderInfo) 后作为 signData 传入 wx.requestVirtualPayment(key 顺序=插入序,须与后端签名串逐字节一致)。
      */
     private Map<String, Object> orderInfo;
 

+ 30 - 71
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -17,12 +17,9 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
 import javax.annotation.Resource;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
 
 /**
@@ -36,9 +33,6 @@ public class VirtualPayService {
     /** 虚拟支付下单场景标识(wx.requestVirtualPayment 固定 URI 前缀) */
     private static final String URI_REQUEST_VIRTUAL_PAYMENT = "requestVirtualPayment";
 
-    /** 道具直购模式(微信虚拟支付官方固定值) */
-    private static final String MODE_SHORT_SERIES_GOODS = "short_series_goods";
-
     /** 签名算法标识 */
     private static final String SIGN_TYPE_HMAC_SHA256 = "HMAC-SHA256";
 
@@ -65,9 +59,6 @@ public class VirtualPayService {
 
     private final RestTemplate restTemplate = new RestTemplate();  // query_order HTTP 调用
 
-    @Value("${wechat.appid}")
-    private String appid;
-
     /** 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开),paySig 签名密钥 */
     @Value("${wechat.appkey}")
     private String appKey;
@@ -76,9 +67,9 @@ public class VirtualPayService {
     @Value("${wechat.sandbox-appkey:}")
     private String sandboxAppKey;
 
-    /** 虚拟支付商户号:占位,当前复用 v3 商户号,联调时替换为虚拟支付商户号 */
-    @Value("${wechat.mch-id}")
-    private String mchId;
+    /** 虚拟支付应用ID(mp-支付基础配置中的 offerId,全应用唯一;productId 才是道具ID) */
+    @Value("${wechat.offer-id}")
+    private String offerId;
 
     /** 虚拟支付环境:0=现网 1=沙箱 */
     @Value("${wechat.virtual-pay-env:0}")
@@ -93,43 +84,42 @@ public class VirtualPayService {
     /**
      * 生成虚拟支付下单参数(道具直购,双签名:paySig + signature)。
      *
-     * @param config      道具映射(offerId 取微信平台道具 ID,即 product_id)
-     * @param outTradeNo  业务订单号(前缀 A/ORD/SUB 决定发货推送路由)
-     * @param totalFee    金额(分,服务端计算传入,防篡改)
-     * @param productInfo 道具名称描述
-     * @param attach      透传业务信息(goodsType:bizKey,便于对账)
+     * <p>orderInfo 按官方 wx.requestVirtualPayment signData 结构组装(camelCase,字段集固定):
+     * offerId/buyQuantity/env/currencyType/productId/goodsPrice/outTradeNo/attach。
+     * offerId = 支付应用ID(mp-支付基础配置,全应用唯一,非道具ID);productId = 道具ID(member1314 等)。
+     *
+     * @param config      道具映射(productId 取微信平台道具 ID)
+     * @param outTradeNo  业务订单号(前缀 A/ORD/SUB 决定发货推送路由,8-32 字符)
+     * @param totalFee    金额(分,服务端计算传入,防篡改;同时作为 goodsPrice 供官方校验后台道具价)
+     * @param productInfo 道具名称描述(仅日志使用,signData 不含此字段)
+     * @param attach      透传业务信息(goodsType:bizKey,发货推送时透传,便于对账)
      * @param sessionKey  用户登录会话密钥(signature 身份签名用,直接字符串编码不 base64 解码)
      */
     public VirtualPayParamsDTO generatePayParams(VirtualGoodsConfig config, String outTradeNo,
                                                  Integer totalFee, String productInfo,
                                                  String attach, String sessionKey) {
-        // 1. orderInfo 组装(官方签名规则:signData 不得包含 platform 字段,否则报 -15005)
+        // 1. orderInfo 按官方 signData 结构组装(LinkedHashMap 保序;后端 fastjson 与前端 JSON.stringify 均保插入序)
         Map<String, Object> orderInfo = new LinkedHashMap<>();
-        orderInfo.put("mch_id", mchId);
-        orderInfo.put("appid", appid);
-        orderInfo.put("out_trade_no", outTradeNo);
-        orderInfo.put("total_fee", totalFee);
-        orderInfo.put("product_info", productInfo);
+        orderInfo.put("offerId", offerId);                    // 支付应用ID(mp-支付基础配置)
+        orderInfo.put("buyQuantity", 1);                      // 道具直购单次购买数量=1
+        orderInfo.put("env", env);                            // 0=现网 1=沙箱
+        orderInfo.put("currencyType", "CNY");
+        orderInfo.put("productId", config.getProductId());    // 道具ID(member1314 等)
+        orderInfo.put("goodsPrice", totalFee);                // 道具单价(分),官方校验与后台道具价一致
+        orderInfo.put("outTradeNo", outTradeNo);
         orderInfo.put("attach", attach);
-        orderInfo.put("mode", MODE_SHORT_SERIES_GOODS);
-        orderInfo.put("offerId", config.getProductId());
 
-        // 2. extInfo 空对象
+        // 2. extInfo 空对象(官方扩展字段,当前不使用;不进签名串)
         Map<String, Object> extInfo = new HashMap<>();
 
-        // 3. signData = orderInfo + extInfo 按官方规则拼接(key 字典序、key=value、& 连接,不含 platform)
-        //    ⚠️ 精确拼接串按设计文档 §5.2 与官方虚拟支付规范推导,联调对照官方 Python 签名脚本验证
-        //    (文档示例输出 c37809f27c...)
-        String signData = buildSignData(orderInfo, extInfo);
-
-        // 4. paySig = hmac_sha256(appKey, "requestVirtualPayment&" + signData)
+        // 3. signData = orderInfo 的 JSON 串(官方签名原文,与前端 JSON.stringify(orderInfo) 逐字节一致)
+        //    paySig    = hmac_sha256(appKey, "requestVirtualPayment&" + signData)
+        //    signature = hmac_sha256(sessionKey, signData)(sessionKey 直接字符串编码,不 base64 解码)
+        String signData = JSON.toJSONString(orderInfo);
         String paySig = HmacSignUtil.hmacSha256Hex(effectiveAppKey(), URI_REQUEST_VIRTUAL_PAYMENT + "&" + signData);
+        String signature = HmacSignUtil.hmacSha256Hex(sessionKey, signData);
 
-        // 5. signature = hmac_sha256(sessionKey, <官方定义的拼接串>),sessionKey 直接字符串编码
-        //    ⚠️ 拼接规则联调时对照官方文档确认
-        String signature = HmacSignUtil.hmacSha256Hex(sessionKey, buildSignData(orderInfo, null));
-
-        // 6. 组装 DTO
+        // 4. 组装 DTO
         VirtualPayParamsDTO dto = new VirtualPayParamsDTO();
         dto.setOrderInfo(orderInfo);
         dto.setExtInfo(extInfo);
@@ -138,8 +128,8 @@ public class VirtualPayService {
         dto.setEnv(env);
         dto.setSignType(SIGN_TYPE_HMAC_SHA256);
 
-        log.info("生成虚拟支付参数: outTradeNo={}, env={}, offerId={}, totalFee={}",
-                outTradeNo, env, config.getProductId(), totalFee);
+        log.info("生成虚拟支付参数: outTradeNo={}, env={}, offerId={}, productId={}, goodsPrice={}, productInfo={}",
+                outTradeNo, env, offerId, config.getProductId(), totalFee, productInfo);
         return dto;
     }
 
@@ -400,7 +390,7 @@ public class VirtualPayService {
     /**
      * 构建 query_order 的 pay_sig(package-private,便于单测断言)。
      * 官方规则:hmac_sha256(appKey, uri + '&' + post_body),uri=/xpay/query_order,
-     * post_body 为原始 JSON body 串(与 generatePayParams 的 buildSignData 字典序不同)。
+     * post_body 为原始 JSON body 串(与 requestVirtualPayment 的 signData 同样是对 JSON 串签名)。
      */
     String buildQueryOrderPaySig(String postBody) {
         return HmacSignUtil.hmacSha256Hex(effectiveAppKey(), "/xpay/query_order&" + postBody);
@@ -422,37 +412,6 @@ public class VirtualPayService {
         }
     }
 
-    /**
-     * 按官方规则拼接签名数据串:key 按字典序排序,以 key=value 形式用 & 连接。
-     * 官方规则要求 signData 不得包含 platform 字段(否则 -15005),此处统一剔除。
-     * extInfo 为 null 时仅拼 orderInfo。
-     */
-    String buildSignData(Map<String, Object> orderInfo, Map<String, Object> extInfo) {
-        Map<String, Object> merged = new LinkedHashMap<>(orderInfo);
-        if (extInfo != null) {
-            merged.putAll(extInfo);
-        }
-        merged.remove("platform");
-
-        List<String> keys = new ArrayList<>(merged.keySet());
-        Collections.sort(keys);
-
-        StringBuilder sb = new StringBuilder();
-        boolean first = true;
-        for (String key : keys) {
-            Object value = merged.get(key);
-            if (value == null) {
-                continue;
-            }
-            if (!first) {
-                sb.append("&");
-            }
-            sb.append(key).append("=").append(value);
-            first = false;
-        }
-        return sb.toString();
-    }
-
     /** 从 payload 按多个候选 key(含大小写不敏感兜底)取字符串值 */
     private String getStringIgnoreCase(Map<String, Object> payload, String... keys) {
         if (payload == null) {

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -80,6 +80,7 @@ wechat:
   sandbox-appkey: ${WECHAT_SANDBOX_APPKEY:sKcsYR0e9T603wvD5yFcvISBcFLh3TXH}  # 虚拟支付 AppKey 沙箱(环境变量可覆盖)
   virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱
   virtual-pay-token: ${WECHAT_VIRTUAL_PAY_TOKEN:cfcVirtualPay2026}  # 虚拟支付消息推送 Token(与小程序后台消息推送配置一致)
+  offer-id: ${WECHAT_VIRTUAL_PAY_OFFER_ID:1450607643}  # 虚拟支付应用ID(mp-支付基础配置中的 offerId;productId 才是道具ID)
   test-phone: "13800138000"  # 测试模式使用的手机号
   alert-template-id: ${WECHAT_ALERT_TEMPLATE_ID:}
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}

+ 32 - 27
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -57,9 +57,8 @@ class VirtualPayServiceTest {
     @Test
     void generatePayParams_returnsCompleteDto() throws Exception {
         // 纯单测环境不注入 Spring @Value,通过反射填充配置字段
-        setField("appid", "wx5ba8038ef16fb245");
+        setField("offerId", "1450607643");
         setField("appKey", "test_app_key");
-        setField("mchId", "1703255039");
         setField("env", 0);
 
         VirtualGoodsConfig config = new VirtualGoodsConfig();
@@ -70,10 +69,14 @@ class VirtualPayServiceTest {
 
         assertNotNull(dto);
         assertNotNull(dto.getOrderInfo());
-        assertEquals("short_series_goods", dto.getOrderInfo().get("mode"));
-        assertEquals("offer_1001", dto.getOrderInfo().get("offerId"));
-        assertEquals(9900, dto.getOrderInfo().get("total_fee"));
-        assertEquals("A20260802001", dto.getOrderInfo().get("out_trade_no"));
+        assertEquals("1450607643", dto.getOrderInfo().get("offerId"));
+        assertEquals(1, dto.getOrderInfo().get("buyQuantity"));
+        assertEquals(0, dto.getOrderInfo().get("env"));
+        assertEquals("CNY", dto.getOrderInfo().get("currencyType"));
+        assertEquals("offer_1001", dto.getOrderInfo().get("productId"));
+        assertEquals(9900, dto.getOrderInfo().get("goodsPrice"));
+        assertEquals("A20260802001", dto.getOrderInfo().get("outTradeNo"));
+        assertEquals("ASSESSMENT_PACKAGE:1", dto.getOrderInfo().get("attach"));
         assertEquals(0, dto.getEnv());
         assertEquals("HMAC-SHA256", dto.getSignType());
         assertNotNull(dto.getExtInfo());
@@ -123,12 +126,13 @@ class VirtualPayServiceTest {
     }
 
     @Test
-    void generatePayParams_paySigMatchesOfficialConcatRule() throws Exception {
-        // 对照官方签名规则: paySig = hmacSha256Hex(appKey, "requestVirtualPayment&" + signData)
-        // signData = orderInfo 按字典序 key=value & 连接(不含 platform)
-        setField("appid", "wx5ba8038ef16fb245");
+    void generatePayParams_signsOfficialJsonSignData() throws Exception {
+        // 对照官方签名规则:
+        //   signData  = orderInfo 的 JSON 串(camelCase 官方字段、插入序,与前端 JSON.stringify 逐字节一致)
+        //   paySig    = hmacSha256Hex(appKey, "requestVirtualPayment&" + signData)
+        //   signature = hmacSha256Hex(sessionKey, signData)
+        setField("offerId", "1450607643");
         setField("appKey", "test_app_key");
-        setField("mchId", "1703255039");
         setField("env", 0);
 
         VirtualGoodsConfig config = new VirtualGoodsConfig();
@@ -137,11 +141,14 @@ class VirtualPayServiceTest {
         VirtualPayParamsDTO dto = virtualPayService.generatePayParams(
                 config, "A20260802001", 9900, "五维测评·基础版", "ASSESSMENT_PACKAGE:1", "test_session_key");
 
-        // 期望 signData:字典序 key=value & 连接(不含 platform)
-        String expectedSignData = "appid=wx5ba8038ef16fb245&attach=ASSESSMENT_PACKAGE:1&mch_id=1703255039&mode=short_series_goods&offerId=offer_1001&out_trade_no=A20260802001&product_info=五维测评·基础版&total_fee=9900";
-        assertEquals(expectedSignData, virtualPayService.buildSignData(dto.getOrderInfo(), dto.getExtInfo()));
-        // paySig 完整拼接断言
+        // 期望 signData:官方 camelCase 字段、插入序序列化(锁定格式,防回归到旧字典序拼接)
+        String expectedSignData = "{\"offerId\":\"1450607643\",\"buyQuantity\":1,\"env\":0,"
+                + "\"currencyType\":\"CNY\",\"productId\":\"offer_1001\",\"goodsPrice\":9900,"
+                + "\"outTradeNo\":\"A20260802001\",\"attach\":\"ASSESSMENT_PACKAGE:1\"}";
+        assertEquals(expectedSignData, JSON.toJSONString(dto.getOrderInfo()));
+        // paySig / signature 均基于同一 JSON 串(与前端实际发送的 signData 逐字节一致)
         assertEquals(HmacSignUtil.hmacSha256Hex("test_app_key", "requestVirtualPayment&" + expectedSignData), dto.getSign());
+        assertEquals(HmacSignUtil.hmacSha256Hex("test_session_key", expectedSignData), dto.getSignature());
     }
 
     @Test
@@ -159,9 +166,8 @@ class VirtualPayServiceTest {
 
     @Test
     void generateMembershipPayParams_returnsDtoForConfiguredLevel() throws Exception {
-        setField("appid", "wx5ba8038ef16fb245");
+        setField("offerId", "1450607643");
         setField("appKey", "test_app_key");
-        setField("mchId", "1703255039");
         setField("env", 0);
 
         PaymentOrder order = new PaymentOrder();
@@ -179,11 +185,11 @@ class VirtualPayServiceTest {
         VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams("ORD20260802001", "test_session_key");
 
         assertNotNull(dto);
-        assertEquals("ORD20260802001", dto.getOrderInfo().get("out_trade_no"));
-        assertEquals(131400, dto.getOrderInfo().get("total_fee"));
+        assertEquals("1450607643", dto.getOrderInfo().get("offerId"));
+        assertEquals("offer_m1", dto.getOrderInfo().get("productId"));
+        assertEquals(131400, dto.getOrderInfo().get("goodsPrice"));
+        assertEquals("ORD20260802001", dto.getOrderInfo().get("outTradeNo"));
         assertEquals("membership:FAMILY", dto.getOrderInfo().get("attach"));
-        assertEquals("offer_m1", dto.getOrderInfo().get("offerId"));
-        assertEquals("家庭会员年卡", dto.getOrderInfo().get("product_info"));
     }
 
     @Test
@@ -223,9 +229,8 @@ class VirtualPayServiceTest {
 
     @Test
     void generateSubscriptionPayParams_returnsDtoForConfiguredLevel() throws Exception {
-        setField("appid", "wx5ba8038ef16fb245");
+        setField("offerId", "1450607643");
         setField("appKey", "test_app_key");
-        setField("mchId", "1703255039");
         setField("env", 0);
 
         MemberSubscriptionOrder order = new MemberSubscriptionOrder();
@@ -243,11 +248,11 @@ class VirtualPayServiceTest {
         VirtualPayParamsDTO dto = virtualPayService.generateSubscriptionPayParams("SUB20260802001", "test_session_key");
 
         assertNotNull(dto);
-        assertEquals("SUB20260802001", dto.getOrderInfo().get("out_trade_no"));
-        assertEquals(36500, dto.getOrderInfo().get("total_fee"));
+        assertEquals("1450607643", dto.getOrderInfo().get("offerId"));
+        assertEquals("offer_sub_l1", dto.getOrderInfo().get("productId"));
+        assertEquals(36500, dto.getOrderInfo().get("goodsPrice"));
+        assertEquals("SUB20260802001", dto.getOrderInfo().get("outTradeNo"));
         assertEquals("subscription:L1", dto.getOrderInfo().get("attach"));
-        assertEquals("offer_sub_l1", dto.getOrderInfo().get("offerId"));
-        assertEquals("一生一世L1", dto.getOrderInfo().get("product_info"));
     }
 
     @Test