Просмотр исходного кода

feat(virtual-pay): 接入现网/沙箱双 AppKey 并补充消息推送 Token 校验

- application.yml: appkey 默认现网 key,新增 sandbox-appkey、virtual-pay-token(均支持环境变量覆盖)
- VirtualPayService: effectiveAppKey() 按 virtual-pay-env 选择签名密钥,paySig/query_order pay_sig 统一切换
- VirtualPayController: 新增 GET /notify URL 验证端点(echostr 校验);POST /notify 增加微信签名校验(sha1(token+timestamp+nonce))防伪造推送
asus 3 месяцев назад
Родитель
Сommit
a7e9023eae

+ 77 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/VirtualPayController.java

@@ -4,13 +4,18 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.etotem.cfc.service.VirtualPayService;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
 import java.io.BufferedReader;
+import java.security.MessageDigest;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -26,10 +31,39 @@ public class VirtualPayController {
     @Resource
     private VirtualPayService virtualPayService;
 
+    /** 微信消息推送 Token(后台【开发管理-开发设置-消息推送】配置,URL 验证与推送签名校验用) */
+    @Value("${wechat.virtual-pay-token:}")
+    private String pushToken;
+
+    /**
+     * 微信服务器 URL 验证(配置消息推送 URL 时微信发 GET 请求校验,原样返回 echostr 即验证通过)。
+     * 微信平台强制要求 GET 校验,属"统一 POST"规范唯一例外。
+     */
+    @RequestMapping(value = "/notify", method = RequestMethod.GET)
+    public String verifyUrl(@RequestParam("signature") String signature,
+                            @RequestParam("timestamp") String timestamp,
+                            @RequestParam("nonce") String nonce,
+                            @RequestParam("echostr") String echostr) {
+        if (checkSignature(signature, timestamp, nonce)) {
+            return echostr;
+        }
+        log.warn("虚拟支付消息推送URL验证签名不匹配: signature={}", signature);
+        return "";
+    }
+
     /** 微信虚拟支付发货/退款等消息推送(xpay_goods_deliver_notify / xpay_refund_notify / 其他) */
     @PostMapping("/notify")
     public Map<String, Object> notify(HttpServletRequest request) {
         try {
+            // 0. 微信消息签名校验(防伪造推送):signature = sha1(sort(token, timestamp, nonce))
+            String signature = request.getParameter("signature");
+            String timestamp = request.getParameter("timestamp");
+            String nonce = request.getParameter("nonce");
+            if (!checkSignature(signature, timestamp, nonce)) {
+                log.error("虚拟支付消息推送签名校验失败,拒绝处理: signature={}, timestamp={}, nonce={}", signature, timestamp, nonce);
+                return failResult("signature check failed");
+            }
+
             // 读原始 body(getReader(),参照 ProductOrderController.notify() L120-155 范式)
             BufferedReader reader = request.getReader();
             StringBuilder sb = new StringBuilder();
@@ -108,6 +142,49 @@ public class VirtualPayController {
         return result;
     }
 
+    /** 微信协议失败应答(非0触发微信重试) */
+    private Map<String, Object> failResult(String errMsg) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("ErrCode", -1);
+        result.put("ErrMsg", errMsg);
+        return result;
+    }
+
+    /**
+     * 微信消息签名校验:signature = sha1(字典序排列 [token, timestamp, nonce] 后拼接的字符串)。
+     * token 未配置时校验失败(防止裸奔收推送)。
+     */
+    private boolean checkSignature(String signature, String timestamp, String nonce) {
+        if (pushToken == null || pushToken.isEmpty()) {
+            log.error("虚拟支付消息推送 Token 未配置(wechat.virtual-pay-token),拒绝处理");
+            return false;
+        }
+        if (signature == null || timestamp == null || nonce == null) {
+            return false;
+        }
+        String[] arr = {pushToken, timestamp, nonce};
+        Arrays.sort(arr);
+        String plain = arr[0] + arr[1] + arr[2];
+        String expect = sha1Hex(plain);
+        return expect.equalsIgnoreCase(signature);
+    }
+
+    /** SHA-1 十六进制摘要(微信消息推送签名用) */
+    private String sha1Hex(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-1");
+            byte[] digest = md.digest(input.getBytes("UTF-8"));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            log.error("SHA-1 计算异常", e);
+            return "";
+        }
+    }
+
     /** iOS 退款问询最低响应(协议规定应答字段,未接入时默认不同意由 Apple 裁决) */
     private Map<String, Object> iosRefundBaseResponse() {
         Map<String, Object> result = new HashMap<>();

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

@@ -72,6 +72,10 @@ public class VirtualPayService {
     @Value("${wechat.appkey}")
     private String appKey;
 
+    /** 虚拟支付沙箱 AppKey(env=1 时使用) */
+    @Value("${wechat.sandbox-appkey:}")
+    private String sandboxAppKey;
+
     /** 虚拟支付商户号:占位,当前复用 v3 商户号,联调时替换为虚拟支付商户号 */
     @Value("${wechat.mch-id}")
     private String mchId;
@@ -80,6 +84,12 @@ public class VirtualPayService {
     @Value("${wechat.virtual-pay-env:0}")
     private Integer env;
 
+    /** 按当前环境选择 AppKey(0=现网 1=沙箱;沙箱 key 未配置时回退现网) */
+    private String effectiveAppKey() {
+        return (env != null && env == 1 && sandboxAppKey != null && !sandboxAppKey.isEmpty())
+                ? sandboxAppKey : appKey;
+    }
+
     /**
      * 生成虚拟支付下单参数(道具直购,双签名:paySig + signature)。
      *
@@ -113,7 +123,7 @@ public class VirtualPayService {
         String signData = buildSignData(orderInfo, extInfo);
 
         // 4. paySig = hmac_sha256(appKey, "requestVirtualPayment&" + signData)
-        String paySig = HmacSignUtil.hmacSha256Hex(appKey, URI_REQUEST_VIRTUAL_PAYMENT + "&" + signData);
+        String paySig = HmacSignUtil.hmacSha256Hex(effectiveAppKey(), URI_REQUEST_VIRTUAL_PAYMENT + "&" + signData);
 
         // 5. signature = hmac_sha256(sessionKey, <官方定义的拼接串>),sessionKey 直接字符串编码
         //    ⚠️ 拼接规则联调时对照官方文档确认
@@ -393,7 +403,7 @@ public class VirtualPayService {
      * post_body 为原始 JSON body 串(与 generatePayParams 的 buildSignData 字典序不同)。
      */
     String buildQueryOrderPaySig(String postBody) {
-        return HmacSignUtil.hmacSha256Hex(appKey, "/xpay/query_order&" + postBody);
+        return HmacSignUtil.hmacSha256Hex(effectiveAppKey(), "/xpay/query_order&" + postBody);
     }
 
     /** query_order 查询结果(内部静态类,免新文件) */

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

@@ -76,8 +76,10 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: false  # 测试模式:跳过微信API调用,使用模拟数据
-  appkey: ${WECHAT_APPKEY:}                # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开)
+  appkey: ${WECHAT_APPKEY:cXqYJseX2MUaRM5kVHLQRskw5l5ZrDNo}          # 虚拟支付 AppKey 现网(环境变量可覆盖)
+  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(与小程序后台消息推送配置一致)
   test-phone: "13800138000"  # 测试模式使用的手机号
   alert-template-id: ${WECHAT_ALERT_TEMPLATE_ID:}
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}