|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|