Explorar o código

feat: VirtualPayService paySig签名/参数生成/发货退款推送处理+sessionKey持久化 JDK原生HMAC-SHA256零新依赖,双签名(paySig+signature),前缀路由,迁移144

asus hai 1 mes
pai
achega
81810055a6

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/HmacSignUtil.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.common;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * HMAC-SHA256 签名工具(JDK 原生 javax.crypto,零第三方依赖)。
+ * 用于小程序虚拟支付 paySig / signature 计算。
+ */
+public final class HmacSignUtil {
+
+    private HmacSignUtil() {
+    }
+
+    /**
+     * 计算 HMAC-SHA256,返回小写十六进制字符串。
+     *
+     * @param key  签名密钥(UTF-8 直接编码,调用方负责传入已解码/未解码的原始密钥)
+     * @param data 待签名数据
+     * @return 64 位小写 hex 摘要
+     */
+    public static String hmacSha256Hex(String key, String data) {
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder(raw.length * 2);
+            for (byte b : raw) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            throw new IllegalStateException("HMAC-SHA256签名失败", e);
+        }
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7299,5 +7299,13 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("创建virtual_goods_config表失败: {}", e.getMessage());
 		}
+
+		// 迁移144: users表添加session_key列(小程序虚拟支付signature签名会话密钥)
+		try {
+			ensureColumn("users", "session_key", "VARCHAR(64) COMMENT '微信登录会话密钥(虚拟支付签名用)'");
+			log.info("已完成users.session_key字段添加");
+		} catch (Exception e) {
+			log.warn("添加users.session_key字段失败: {}", e.getMessage());
+		}
 	}
 }

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/VirtualPayParamsDTO.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 小程序虚拟支付下单返回体(wx.requestVirtualPayment 入参)。
+ * orderInfo/extInfo 直接透传给前端;sign=paySig(appKey签名),signature=用户身份签名(sessionKey签名)。
+ */
+@Data
+public class VirtualPayParamsDTO {
+
+    /**
+     * 订单信息:{mch_id, appid, out_trade_no, total_fee, product_info, attach, mode, offerId, ...}
+     */
+    private Map<String, Object> orderInfo;
+
+    /**
+     * 扩展信息(当前为空对象即可)
+     */
+    private Map<String, Object> extInfo;
+
+    /**
+     * paySig:HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)
+     */
+    private String sign;
+
+    /**
+     * 用户身份签名:HMAC-SHA256(sessionKey, <官方定义的拼接串>),道具直购必需
+     */
+    private String signature;
+
+    /**
+     * 虚拟支付环境:0=现网 1=沙箱(配置 wechat.virtual-pay-env)
+     */
+    private Integer env;
+
+    /**
+     * 签名算法标识:"HMAC-SHA256"
+     */
+    private String signType;
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -41,6 +41,10 @@ public class User implements Serializable {
 
     private String phone; // 手机号
 
+    /** 微信登录会话密钥(虚拟支付 signature 身份签名用),每次登录更新 */
+    @TableField("session_key")
+    private String sessionKey;
+
     private Date birthday; // 生日
 
     /** 出生时辰: 子丑寅卯辰巳午未申酉戌亥 */

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

@@ -113,6 +113,7 @@ private FamilyInvitationService familyInvitationService;
                 user.setAvatar(dto.getAvatar());
                 user.setPhone(phone);
                 user.setPassword("");
+                user.setSessionKey(sessionData.get("session_key"));
                 user.setCreatedAt(new Date());
                 user.setUpdatedAt(new Date());
                 userMapper.insert(user);
@@ -121,16 +122,18 @@ private FamilyInvitationService familyInvitationService;
                 // 6. 手机号已存在但openid不同,绑定openid
                 user.setOpenid(openid);
                 user.setUnionid(unionid != null ? unionid : "");
+                user.setSessionKey(sessionData.get("session_key"));
                 user.setUpdatedAt(new Date());
                 userMapper.updateById(user);
             }
         } else {
-            // 7. openid已存在,更新手机号(如果手机号变了)
+            // 7. openid已存在,刷新sessionKey(虚拟支付signature签名必需,每次登录更新)+ 更新手机号(如果手机号变了)
+            user.setSessionKey(sessionData.get("session_key"));
             if (phone != null && !phone.equals(user.getPhone())) {
                 user.setPhone(phone);
-                user.setUpdatedAt(new Date());
-                userMapper.updateById(user);
             }
+            user.setUpdatedAt(new Date());
+            userMapper.updateById(user);
         }
 
         // P3: admin创建的用户可能 familyId=0L,懒创建家庭
@@ -269,6 +272,11 @@ 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("该账号不允许登录小程序");

+ 234 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -0,0 +1,234 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.HmacSignUtil;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 小程序虚拟支付服务:paySig 签名、下单参数生成、发货/退款推送处理。
+ * 签名规则以设计文档 §5.2 与微信官方虚拟支付规范为准。
+ */
+@Slf4j
+@Service
+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";
+
+    @Resource
+    private AssessmentOrderService assessmentOrderService;
+
+    @Value("${wechat.appid}")
+    private String appid;
+
+    /** 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开),paySig 签名密钥 */
+    @Value("${wechat.appkey}")
+    private String appKey;
+
+    /** 虚拟支付商户号:占位,当前复用 v3 商户号,联调时替换为虚拟支付商户号 */
+    @Value("${wechat.mch-id}")
+    private String mchId;
+
+    /** 虚拟支付环境:0=现网 1=沙箱 */
+    @Value("${wechat.virtual-pay-env:0}")
+    private Integer env;
+
+    /**
+     * 生成虚拟支付下单参数(道具直购,双签名:paySig + signature)。
+     *
+     * @param config      道具映射(offerId 取微信平台道具 ID,即 product_id)
+     * @param outTradeNo  业务订单号(前缀 A/ORD/SUB 决定发货推送路由)
+     * @param totalFee    金额(分,服务端计算传入,防篡改)
+     * @param productInfo 道具名称描述
+     * @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)
+        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("attach", attach);
+        orderInfo.put("mode", MODE_SHORT_SERIES_GOODS);
+        orderInfo.put("offerId", config.getProductId());
+
+        // 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)
+        String paySig = HmacSignUtil.hmacSha256Hex(appKey, URI_REQUEST_VIRTUAL_PAYMENT + "&" + signData);
+
+        // 5. signature = hmac_sha256(sessionKey, <官方定义的拼接串>),sessionKey 直接字符串编码
+        //    ⚠️ 拼接规则联调时对照官方文档确认
+        String signature = HmacSignUtil.hmacSha256Hex(sessionKey, buildSignData(orderInfo, null));
+
+        // 6. 组装 DTO
+        VirtualPayParamsDTO dto = new VirtualPayParamsDTO();
+        dto.setOrderInfo(orderInfo);
+        dto.setExtInfo(extInfo);
+        dto.setSign(paySig);
+        dto.setSignature(signature);
+        dto.setEnv(env);
+        dto.setSignType(SIGN_TYPE_HMAC_SHA256);
+
+        log.info("生成虚拟支付参数: outTradeNo={}, env={}, offerId={}, totalFee={}",
+                outTradeNo, env, config.getProductId(), totalFee);
+        return dto;
+    }
+
+    /**
+     * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
+     * 按 OutTradeNo 前缀路由:A→测评,ORD→会员(TODO Task 7),SUB→订阅(TODO Task 8)。
+     * 返回微信协议 {@code {"ErrCode":0,"ErrMsg":"success"}}。
+     */
+    public Map<String, Object> handleDeliverNotify(Map<String, Object> payload) {
+        try {
+            String outTradeNo = getStringIgnoreCase(payload, "OutTradeNo", "out_trade_no");
+            String transactionId = getStringIgnoreCase(payload, "TransactionId", "transaction_id");
+
+            if (outTradeNo == null || outTradeNo.isEmpty()) {
+                log.error("虚拟支付发货推送缺少OutTradeNo: {}", payload);
+                return failResult("参数错误");
+            }
+
+            log.info("虚拟支付发货推送: outTradeNo={}, transactionId={}", outTradeNo, transactionId);
+
+            if (outTradeNo.startsWith("A")) {
+                // 测评订单:标记已付 + 佣金结算
+                assessmentOrderService.paySuccess(outTradeNo, transactionId, "virtual");
+            } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
+                // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 标记已付 + 解锁权益,暂未接入,记录日志返回成功
+                log.warn("虚拟支付发货推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);
+            } else {
+                log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
+            }
+            return successResult();
+        } catch (Exception e) {
+            log.error("处理虚拟支付发货推送异常", e);
+            return failResult("处理异常");
+        }
+    }
+
+    /**
+     * 处理虚拟支付退款推送(xpay_refund_notify)。
+     * 按 OutTradeNo 前缀路由:A→测评退款,ORD/SUB→TODO Task 7/8。
+     */
+    public Map<String, Object> handleRefundNotify(Map<String, Object> payload) {
+        try {
+            String outTradeNo = getStringIgnoreCase(payload, "OutTradeNo", "out_trade_no");
+
+            if (outTradeNo == null || outTradeNo.isEmpty()) {
+                log.error("虚拟支付退款推送缺少OutTradeNo: {}", payload);
+                return failResult("参数错误");
+            }
+
+            log.info("虚拟支付退款推送: outTradeNo={}", outTradeNo);
+
+            if (outTradeNo.startsWith("A")) {
+                assessmentOrderService.refundOrder(outTradeNo);
+            } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
+                // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 退款处理,暂未接入
+                log.warn("虚拟支付退款推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);
+            } else {
+                log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
+            }
+            return successResult();
+        } catch (Exception e) {
+            log.error("处理虚拟支付退款推送异常", e);
+            return failResult("处理异常");
+        }
+    }
+
+    /**
+     * 按官方规则拼接签名数据串:key 按字典序排序,以 key=value 形式用 & 连接。
+     * 官方规则要求 signData 不得包含 platform 字段(否则 -15005),此处统一剔除。
+     * extInfo 为 null 时仅拼 orderInfo。
+     */
+    private 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) {
+            return null;
+        }
+        for (String key : keys) {
+            Object value = payload.get(key);
+            if (value != null) {
+                return String.valueOf(value);
+            }
+        }
+        for (Map.Entry<String, Object> entry : payload.entrySet()) {
+            for (String key : keys) {
+                if (key.equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) {
+                    return String.valueOf(entry.getValue());
+                }
+            }
+        }
+        return null;
+    }
+
+    private Map<String, Object> successResult() {
+        Map<String, Object> result = new HashMap<>();
+        result.put("ErrCode", 0);
+        result.put("ErrMsg", "success");
+        return result;
+    }
+
+    private Map<String, Object> failResult(String msg) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("ErrCode", 1);
+        result.put("ErrMsg", msg);
+        return result;
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS users (
     nickname VARCHAR(50),
     avatar VARCHAR(255),
     password VARCHAR(128),
+    session_key VARCHAR(64) COMMENT '微信登录会话密钥(虚拟支付签名用)',
     id_card VARCHAR(18),
     real_name VARCHAR(50),
     phone VARCHAR(11),

+ 112 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -0,0 +1,112 @@
+package com.etotem.cfc.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.etotem.cfc.common.HmacSignUtil;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+
+@ExtendWith(MockitoExtension.class)
+class VirtualPayServiceTest {
+
+    @Mock
+    private AssessmentOrderService assessmentOrderService;
+
+    @InjectMocks
+    private VirtualPayService virtualPayService;
+
+    @Test
+    void hmacSha256Hex_matchesRfc4231TestVector() {
+        // RFC 4231 Test Case 2 标准测试向量
+        String actual = HmacSignUtil.hmacSha256Hex("Jefe", "what do ya want for nothing?");
+        assertEquals("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", actual);
+    }
+
+    @Test
+    void generatePayParams_returnsCompleteDto() throws Exception {
+        // 纯单测环境不注入 Spring @Value,通过反射填充配置字段
+        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");
+
+        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(0, dto.getEnv());
+        assertEquals("HMAC-SHA256", dto.getSignType());
+        assertNotNull(dto.getExtInfo());
+        assertNotNull(dto.getSign());
+        assertFalse(dto.getSign().isEmpty());
+        assertNotNull(dto.getSignature());
+        assertFalse(dto.getSignature().isEmpty());
+        // 双签名值不同(paySig 用 appKey、signature 用 sessionKey)
+        org.junit.jupiter.api.Assertions.assertNotEquals(dto.getSign(), dto.getSignature());
+    }
+
+    @Test
+    void handleDeliverNotify_assessmentPrefixRoutesToPaySuccess() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("OutTradeNo", "A20260802001");
+        payload.put("TransactionId", "WX1234567890");
+
+        Map<String, Object> result = virtualPayService.handleDeliverNotify(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        assertEquals("success", result.get("ErrMsg"));
+        verify(assessmentOrderService, times(1)).paySuccess("A20260802001", "WX1234567890", "virtual");
+    }
+
+    @Test
+    void handleDeliverNotify_missingOutTradeNoReturnsFail() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("TransactionId", "WX1234567890");
+
+        Map<String, Object> result = virtualPayService.handleDeliverNotify(payload);
+
+        assertEquals(1, result.get("ErrCode"));
+        verify(assessmentOrderService, times(0)).paySuccess(org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
+    }
+
+    @Test
+    void handleRefundNotify_assessmentPrefixCallsRefundOrder() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("OutTradeNo", "A20260802001");
+
+        Map<String, Object> result = virtualPayService.handleRefundNotify(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        assertEquals("success", result.get("ErrMsg"));
+        verify(assessmentOrderService, times(1)).refundOrder("A20260802001");
+    }
+
+    private void setField(String name, Object value) throws Exception {
+        Field field = VirtualPayService.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(virtualPayService, value);
+    }
+}