|
@@ -0,0 +1,449 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.etotem.cfc.common.HmacSignUtil;
|
|
|
|
|
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.AssessmentOrder;
|
|
|
|
|
+import com.etotem.cfc.entity.MemberSubscriptionOrder;
|
|
|
|
|
+import com.etotem.cfc.entity.PaymentOrder;
|
|
|
|
|
+import com.etotem.cfc.entity.VirtualGoodsConfig;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
|
|
+import org.springframework.http.MediaType;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+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 SIGN_TYPE_HMAC_SHA256 = "HMAC-SHA256";
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AssessmentOrderService assessmentOrderService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private VirtualGoodsConfigService virtualGoodsConfigService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private PackagePurchaseService packagePurchaseService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MembershipService membershipService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MemberSubscriptionService memberSubscriptionService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private WechatService wechatService; // 复用 getAccessToken()(带缓存)
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.test-mode:false}")
|
|
|
|
|
+ private boolean testMode; // 测试模式:跳过微信 API 调用
|
|
|
|
|
+
|
|
|
|
|
+ private final RestTemplate restTemplate = new RestTemplate(); // query_order HTTP 调用
|
|
|
|
|
+
|
|
|
|
|
+ /** 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开),paySig 签名密钥 */
|
|
|
|
|
+ @Value("${wechat.appkey}")
|
|
|
|
|
+ private String appKey;
|
|
|
|
|
+
|
|
|
|
|
+ /** 虚拟支付沙箱 AppKey(env=1 时使用) */
|
|
|
|
|
+ @Value("${wechat.sandbox-appkey:}")
|
|
|
|
|
+ private String sandboxAppKey;
|
|
|
|
|
+
|
|
|
|
|
+ /** 虚拟支付应用ID(mp-支付基础配置中的 offerId,全应用唯一;productId 才是道具ID) */
|
|
|
|
|
+ @Value("${wechat.offer-id}")
|
|
|
|
|
+ private String offerId;
|
|
|
|
|
+
|
|
|
|
|
+ /** 虚拟支付环境:0=现网 1=沙箱 */
|
|
|
|
|
+ @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)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <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 结构组装(LinkedHashMap 保序;后端 fastjson 与前端 JSON.stringify 均保插入序)
|
|
|
|
|
+ Map<String, Object> orderInfo = new LinkedHashMap<>();
|
|
|
|
|
+ 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);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. extInfo 空对象(官方扩展字段,当前不使用;不进签名串)
|
|
|
|
|
+ Map<String, Object> extInfo = new HashMap<>();
|
|
|
|
|
+
|
|
|
|
|
+ // 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);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 组装 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={}, productId={}, goodsPrice={}, productInfo={}",
|
|
|
|
|
+ outTradeNo, env, offerId, config.getProductId(), totalFee, productInfo);
|
|
|
|
|
+ return dto;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 测评订单虚拟支付下单参数编排(防循环依赖:编排放本类,AssessmentOrderService 单向被依赖)。
|
|
|
|
|
+ * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param appointmentId 前端可选传入,回填订单修复 appointmentId 链路断点
|
|
|
|
|
+ */
|
|
|
|
|
+ public VirtualPayParamsDTO generateAssessmentPayParams(String orderNo, String payType,
|
|
|
|
|
+ String sessionKey, Long appointmentId) {
|
|
|
|
|
+ // 1. 查订单 + 校验 pending
|
|
|
|
|
+ AssessmentOrder order = assessmentOrderService.getByOrderNo(orderNo);
|
|
|
|
|
+ if (order == null || !"pending".equals(order.getStatus())) {
|
|
|
|
|
+ log.warn("生成测评支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 回填 appointmentId(修复链路断点:createOrder 从不赋值)
|
|
|
|
|
+ if (appointmentId != null && order.getAppointmentId() == null) {
|
|
|
|
|
+ order.setAppointmentId(appointmentId);
|
|
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
|
|
+ assessmentOrderService.updateById(order);
|
|
|
|
|
+ log.info("支付下单回填预约关联: orderNo={}, appointmentId={}", orderNo, appointmentId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 查 virtual_goods_config:goodsType=ASSESSMENT_PACKAGE, bizKey=订单packageId → null → 调用方 46001
|
|
|
|
|
+ VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("ASSESSMENT_PACKAGE",
|
|
|
|
|
+ String.valueOf(order.getPackageId()));
|
|
|
|
|
+ if (config == null) {
|
|
|
|
|
+ log.warn("测评道具未配置或停用: packageId={}", order.getPackageId());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 服务端重算金额(不信任前端传参):total_fee = getPackagePrice(...).totalPrice
|
|
|
|
|
+ Map<String, Object> price = packagePurchaseService.getPackagePrice(order.getPackageId(), order.getGuideId(), null);
|
|
|
|
|
+ if (price == null || price.get("totalPrice") == null) {
|
|
|
|
|
+ log.warn("测评套餐服务端计价失败: packageId={}", order.getPackageId());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Integer totalFee = ((Number) price.get("totalPrice")).intValue();
|
|
|
|
|
+ if (totalFee < 100) { // 金额 >= 1元
|
|
|
|
|
+ log.warn("测评套餐金额异常(<1元): packageId={}, totalFee={}", order.getPackageId(), totalFee);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
|
|
|
|
|
+ String attach = "assessment:" + order.getPackageId();
|
|
|
|
|
+ return generatePayParams(config, orderNo, totalFee, order.getPackageName(), attach, sessionKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 会员订单虚拟支付下单参数编排(防循环依赖:编排放本类,MembershipService 单向被依赖)。
|
|
|
|
|
+ * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
|
|
|
|
|
+ */
|
|
|
|
|
+ public VirtualPayParamsDTO generateMembershipPayParams(String orderNo, String sessionKey) {
|
|
|
|
|
+ // 1. 查订单 + 校验 pending
|
|
|
|
|
+ PaymentOrder order = membershipService.getByOrderNo(orderNo);
|
|
|
|
|
+ if (order == null || !"pending".equals(order.getStatus())) {
|
|
|
|
|
+ log.warn("生成会员支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 查 virtual_goods_config:goodsType=MEMBERSHIP, bizKey=订单levelCode → null → 调用方 46001
|
|
|
|
|
+ VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("MEMBERSHIP", order.getLevelCode());
|
|
|
|
|
+ if (config == null) {
|
|
|
|
|
+ log.warn("会员道具未配置或停用: levelCode={}", order.getLevelCode());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 金额 = order.amount(服务端已算,不重算;避免前端篡改),校验 >= 1元
|
|
|
|
|
+ if (order.getAmount() == null || order.getAmount() < 100) {
|
|
|
|
|
+ log.warn("会员订单金额异常(<1元): orderNo={}, amount={}", orderNo, order.getAmount());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
|
|
|
|
|
+ String attach = "membership:" + order.getLevelCode();
|
|
|
|
|
+ return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 订阅订单虚拟支付下单参数编排(防循环依赖:编排放本类,MemberSubscriptionService 单向被依赖)。
|
|
|
|
|
+ * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
|
|
|
|
|
+ */
|
|
|
|
|
+ public VirtualPayParamsDTO generateSubscriptionPayParams(String orderNo, String sessionKey) {
|
|
|
|
|
+ // 1. 查订单 + 校验 pending
|
|
|
|
|
+ MemberSubscriptionOrder order = memberSubscriptionService.getByOrderNo(orderNo);
|
|
|
|
|
+ if (order == null || !"pending".equals(order.getStatus())) {
|
|
|
|
|
+ log.warn("生成订阅支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 查 virtual_goods_config:goodsType=SUBSCRIPTION, bizKey=订单level → null → 调用方 46001
|
|
|
|
|
+ VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("SUBSCRIPTION", order.getLevel());
|
|
|
|
|
+ if (config == null) {
|
|
|
|
|
+ log.warn("订阅道具未配置或停用: level={}", order.getLevel());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 金额 = order.amount(服务端已算,不重算;避免前端篡改),校验 >= 1元
|
|
|
|
|
+ if (order.getAmount() == null || order.getAmount() < 100) {
|
|
|
|
|
+ log.warn("订阅订单金额异常(<1元): orderNo={}, amount={}", orderNo, order.getAmount());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
|
|
|
|
|
+ String attach = "subscription:" + order.getLevel();
|
|
|
|
|
+ return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
|
|
|
|
|
+ * 按 OutTradeNo 前缀路由:A→测评,ORD→会员,SUB→订阅。
|
|
|
|
|
+ * 返回微信协议 {@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 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.handleVirtualPaySuccess(outTradeNo, transactionId);
|
|
|
|
|
+ } else if (outTradeNo.startsWith("ORD")) {
|
|
|
|
|
+ // 会员订单:复用 processPaymentCallback(幂等已内置 L341-343)完成全部开通
|
|
|
|
|
+ membershipService.processPaymentCallback(outTradeNo, transactionId, "virtual");
|
|
|
|
|
+ } else if (outTradeNo.startsWith("SUB")) {
|
|
|
|
|
+ // 幂等:已 paid/refunded 的重复推送直接返回成功,避免重复开通/重复佣金结算(spec §6.2)
|
|
|
|
|
+ MemberSubscriptionOrder subOrder = memberSubscriptionService.getByOrderNo(outTradeNo);
|
|
|
|
|
+ if (subOrder != null && ("paid".equals(subOrder.getStatus()) || "refunded".equals(subOrder.getStatus()))) {
|
|
|
|
|
+ log.info("虚拟支付发货推送重复通知,订阅订单已终态: outTradeNo={}, status={}", outTradeNo, subOrder.getStatus());
|
|
|
|
|
+ } else if (subOrder != null) {
|
|
|
|
|
+ // 订阅订单:激活订阅(置旧过期 + 插新订阅 + 订单置paid + 分配管家 + 佣金)
|
|
|
|
|
+ memberSubscriptionService.activateSubscription(subOrder.getFamilyId(), subOrder.getLevel(),
|
|
|
|
|
+ subOrder.getAmount(), "virtual", transactionId, outTradeNo);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.warn("虚拟支付发货推送订阅订单不存在: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ }
|
|
|
|
|
+ return successResult();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("处理虚拟支付发货推送异常", e);
|
|
|
|
|
+ return failResult("处理异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理虚拟支付退款推送(xpay_refund_notify)。
|
|
|
|
|
+ * 按 OutTradeNo 前缀路由:A→测评退款,SUB→订阅退款,ORD→TODO。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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 successResult();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.info("虚拟支付退款推送: outTradeNo={}", outTradeNo);
|
|
|
|
|
+
|
|
|
|
|
+ if (outTradeNo.startsWith("A")) {
|
|
|
|
|
+ assessmentOrderService.refundOrder(outTradeNo);
|
|
|
|
|
+ } else if (outTradeNo.startsWith("SUB")) {
|
|
|
|
|
+ // 订阅退款:订单置 refunded + 订阅置 expired(iOS App Store 退款场景)
|
|
|
|
|
+ memberSubscriptionService.refundByOrderNo(outTradeNo);
|
|
|
|
|
+ } else if (outTradeNo.startsWith("ORD")) {
|
|
|
|
|
+ // TODO 后续: 会员退款处理
|
|
|
|
|
+ log.warn("虚拟支付退款推送暂未接入会员处理: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ }
|
|
|
|
|
+ return successResult();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("处理虚拟支付退款推送异常", e);
|
|
|
|
|
+ return failResult("处理异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 查询虚拟支付订单实际支付状态(防 success 回调丢失的兜底,spec §7)。
|
|
|
|
|
+ * 按官方规范:pay_sig = to_hex(hmac_sha256(appKey, "/xpay/query_order&" + post_body)),
|
|
|
|
|
+ * post_body 必须与真正发送的 HTTP body 完全一致。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param outTradeNo 业务订单号(A/ORD/SUB 前缀)
|
|
|
|
|
+ * @param openid 用户 openid(官方必填,调用方负责解析)
|
|
|
|
|
+ * @return 查询结果;查询失败/响应异常返回 null(调用方不得据此关单,防误杀已支付订单)
|
|
|
|
|
+ */
|
|
|
|
|
+ public QueryOrderResult queryOrder(String outTradeNo, String openid) {
|
|
|
|
|
+ // 1. 测试模式:不调微信,返回"未支付"模拟结果(status=1),让超时关单路径在测试环境可跑
|
|
|
|
|
+ if (testMode) {
|
|
|
|
|
+ log.info("【测试模式】跳过query_order查询: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ return new QueryOrderResult(false, 1, null);
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 2. 组装 body(LinkedHashMap 保序;order_id 传业务订单号)
|
|
|
|
|
+ Map<String, Object> body = new LinkedHashMap<>();
|
|
|
|
|
+ body.put("openid", openid);
|
|
|
|
|
+ body.put("env", env);
|
|
|
|
|
+ body.put("order_id", outTradeNo);
|
|
|
|
|
+ body.put("wx_order_id", "");
|
|
|
|
|
+
|
|
|
|
|
+ // 3. post_body = 序列化结果,签名与发送必须用同一字符串(官方强调完全一致)
|
|
|
|
|
+ String postBody = JSON.toJSONString(body);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. pay_sig = hmac_sha256(appKey, "/xpay/query_order&" + postBody)
|
|
|
|
|
+ String paySig = buildQueryOrderPaySig(postBody);
|
|
|
|
|
+
|
|
|
|
|
+ // 5. HTTP 调用(access_token 复用 WechatService 缓存)
|
|
|
|
|
+ String url = "https://api.weixin.qq.com/xpay/query_order?access_token="
|
|
|
|
|
+ + wechatService.getAccessToken() + "&pay_sig=" + paySig;
|
|
|
|
|
+
|
|
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(postBody, headers);
|
|
|
|
|
+ String respBody = restTemplate.postForObject(url, entity, String.class);
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 解析响应
|
|
|
|
|
+ JSONObject resp = JSON.parseObject(respBody);
|
|
|
|
|
+ int errcode = resp.getIntValue("errcode");
|
|
|
|
|
+ if (errcode != 0) {
|
|
|
|
|
+ log.warn("query_order返回错误: outTradeNo={}, errcode={}, errmsg={}",
|
|
|
|
|
+ outTradeNo, errcode, resp.getString("errmsg"));
|
|
|
|
|
+ return null; // 查询失败:调用方不得关单
|
|
|
|
|
+ }
|
|
|
|
|
+ JSONObject order = resp.getJSONObject("order");
|
|
|
|
|
+ if (order == null) {
|
|
|
|
|
+ log.warn("query_order响应缺少order: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ int status = order.getIntValue("status");
|
|
|
|
|
+ String wxpayOrderId = order.getString("wxpay_order_id");
|
|
|
|
|
+ boolean paid = status == 2 || status == 3 || status == 4; // 已支付判定
|
|
|
|
|
+ log.info("query_order结果: outTradeNo={}, status={}, paid={}", outTradeNo, status, paid);
|
|
|
|
|
+ return new QueryOrderResult(paid, status, wxpayOrderId);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("query_order查询异常: outTradeNo={}", outTradeNo, e);
|
|
|
|
|
+ return null; // 异常:调用方不得关单
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构建 query_order 的 pay_sig(package-private,便于单测断言)。
|
|
|
|
|
+ * 官方规则:hmac_sha256(appKey, uri + '&' + post_body),uri=/xpay/query_order,
|
|
|
|
|
+ * post_body 为原始 JSON body 串(与 requestVirtualPayment 的 signData 同样是对 JSON 串签名)。
|
|
|
|
|
+ */
|
|
|
|
|
+ String buildQueryOrderPaySig(String postBody) {
|
|
|
|
|
+ return HmacSignUtil.hmacSha256Hex(effectiveAppKey(), "/xpay/query_order&" + postBody);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** query_order 查询结果(内部静态类,免新文件) */
|
|
|
|
|
+ public static class QueryOrderResult {
|
|
|
|
|
+ /** 是否已支付(status∈{2,3,4}) */
|
|
|
|
|
+ public final boolean paid;
|
|
|
|
|
+ /** 微信侧订单状态枚举 */
|
|
|
|
|
+ public final int status;
|
|
|
|
|
+ /** 微信交易单号(order.wxpay_order_id) */
|
|
|
|
|
+ public final String wxpayOrderId;
|
|
|
|
|
+
|
|
|
|
|
+ public QueryOrderResult(boolean paid, int status, String wxpayOrderId) {
|
|
|
|
|
+ this.paid = paid;
|
|
|
|
|
+ this.status = status;
|
|
|
|
|
+ this.wxpayOrderId = wxpayOrderId;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|