Sfoglia il codice sorgente

fix: 虚拟支付Task4评审修复 sessionKey防序列化泄露/发货推送幂等/缺参ack/签名断言

@JsonIgnore敏感凭据,A路径查单防重复佣金结算,signData字典序端到端断言
asus 2 mesi fa
parent
commit
d844c35712

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableLogic;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import lombok.Data;
 
 import java.io.Serializable;
@@ -41,7 +42,8 @@ public class User implements Serializable {
 
     private String phone; // 手机号
 
-    /** 微信登录会话密钥(虚拟支付 signature 身份签名用),每次登录更新 */
+    /** 微信登录会话密钥(虚拟支付 signature 身份签名用),敏感凭据,禁止序列化返回前端 */
+    @JsonIgnore
     @TableField("session_key")
     private String sessionKey;
 

+ 5 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -272,16 +272,16 @@ private FamilyInvitationService familyInvitationService;
             throw new RuntimeException("用户不存在,请先登录");
         }
 
-        // 持久化最新 sessionKey(虚拟支付 signature 签名用)
-        user.setSessionKey(sessionData.get("session_key"));
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
-
         // 管理员不能登录小程序
         if ("admin".equals(user.getRole())) {
             throw new RuntimeException("该账号不允许登录小程序");
         }
 
+        // 持久化最新 sessionKey(虚拟支付 signature 签名用)
+        user.setSessionKey(sessionData.get("session_key"));
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
         // 3. 懒创建家庭(admin创建的用户可能 familyId=0L)
         ensureUserHasFamily(user);
 

+ 12 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -114,13 +115,19 @@ public class VirtualPayService {
             String transactionId = getStringIgnoreCase(payload, "TransactionId", "transaction_id");
 
             if (outTradeNo == null || outTradeNo.isEmpty()) {
-                log.error("虚拟支付发货推送缺少OutTradeNo: {}", payload);
-                return failResult("参数错误");
+                log.error("虚拟支付发货推送缺少OutTradeNo,返回成功避免微信重试: {}", payload);
+                return successResult();
             }
 
             log.info("虚拟支付发货推送: outTradeNo={}, transactionId={}", outTradeNo, transactionId);
 
             if (outTradeNo.startsWith("A")) {
+                // 幂等:已 paid/refunded 的重复推送直接返回成功,避免重复佣金结算(spec §6.2)
+                AssessmentOrder existing = assessmentOrderService.getByOrderNo(outTradeNo);
+                if (existing != null && ("paid".equals(existing.getStatus()) || "refunded".equals(existing.getStatus()))) {
+                    log.info("虚拟支付发货推送重复通知,订单已终态: outTradeNo={}, status={}", outTradeNo, existing.getStatus());
+                    return successResult();
+                }
                 // 测评订单:标记已付 + 佣金结算
                 assessmentOrderService.paySuccess(outTradeNo, transactionId, "virtual");
             } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
@@ -145,8 +152,8 @@ public class VirtualPayService {
             String outTradeNo = getStringIgnoreCase(payload, "OutTradeNo", "out_trade_no");
 
             if (outTradeNo == null || outTradeNo.isEmpty()) {
-                log.error("虚拟支付退款推送缺少OutTradeNo: {}", payload);
-                return failResult("参数错误");
+                log.error("虚拟支付退款推送缺少OutTradeNo,返回成功避免微信重试: {}", payload);
+                return successResult();
             }
 
             log.info("虚拟支付退款推送: outTradeNo={}", outTradeNo);
@@ -171,7 +178,7 @@ public class VirtualPayService {
      * 官方规则要求 signData 不得包含 platform 字段(否则 -15005),此处统一剔除。
      * extInfo 为 null 时仅拼 orderInfo。
      */
-    private String buildSignData(Map<String, Object> orderInfo, Map<String, Object> extInfo) {
+    String buildSignData(Map<String, Object> orderInfo, Map<String, Object> extInfo) {
         Map<String, Object> merged = new LinkedHashMap<>(orderInfo);
         if (extInfo != null) {
             merged.putAll(extInfo);

+ 24 - 2
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -81,13 +81,13 @@ class VirtualPayServiceTest {
     }
 
     @Test
-    void handleDeliverNotify_missingOutTradeNoReturnsFail() {
+    void handleDeliverNotify_missingOutTradeNoReturnsAck() {
         Map<String, Object> payload = new HashMap<>();
         payload.put("TransactionId", "WX1234567890");
 
         Map<String, Object> result = virtualPayService.handleDeliverNotify(payload);
 
-        assertEquals(1, result.get("ErrCode"));
+        assertEquals(0, result.get("ErrCode"));
         verify(assessmentOrderService, times(0)).paySuccess(org.mockito.ArgumentMatchers.any(),
                 org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
     }
@@ -104,6 +104,28 @@ class VirtualPayServiceTest {
         verify(assessmentOrderService, times(1)).refundOrder("A20260802001");
     }
 
+    @Test
+    void generatePayParams_paySigMatchesOfficialConcatRule() throws Exception {
+        // 对照官方签名规则: paySig = hmacSha256Hex(appKey, "requestVirtualPayment&" + signData)
+        // signData = orderInfo 按字典序 key=value & 连接(不含 platform)
+        setField("appid", "wx5ba8038ef16fb245");
+        setField("appKey", "test_app_key");
+        setField("mchId", "1703255039");
+        setField("env", 0);
+
+        VirtualGoodsConfig config = new VirtualGoodsConfig();
+        config.setProductId("offer_1001");
+
+        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 完整拼接断言
+        assertEquals(HmacSignUtil.hmacSha256Hex("test_app_key", "requestVirtualPayment&" + expectedSignData), dto.getSign());
+    }
+
     private void setField(String name, Object value) throws Exception {
         Field field = VirtualPayService.class.getDeclaredField(name);
         field.setAccessible(true);