|
|
@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.ccb.MerchantRecordDO;
|
|
|
import cn.iocoder.yudao.module.system.dal.mysql.biz.ExpenseOrderMapper;
|
|
|
import cn.iocoder.yudao.module.system.dal.mysql.ccb.MerchantRecordMapper;
|
|
|
import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
|
|
|
+import cn.iocoder.yudao.module.system.enums.common.BooleanEnum;
|
|
|
import cn.iocoder.yudao.module.system.service.ccb.CcbBusinessService;
|
|
|
import cn.iocoder.yudao.module.system.util.pay.CcbPayConstant;
|
|
|
import cn.iocoder.yudao.module.system.util.pay.CcbPayUtils;
|
|
|
@@ -29,7 +30,9 @@ import javax.annotation.security.PermitAll;
|
|
|
import javax.servlet.http.HttpServletResponse;
|
|
|
import java.io.IOException;
|
|
|
import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
import java.util.Map;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
|
|
|
|
|
@@ -64,9 +67,27 @@ public class CcbWxUnifiedOrderController {
|
|
|
// 商户信息
|
|
|
MerchantRecordDO merchantRecordDO = merchantRecordMapper.selectOne(new LambdaQueryWrapperX<MerchantRecordDO>()
|
|
|
.eq(MerchantRecordDO::getTenantId, tenantId));
|
|
|
+ if (merchantRecordDO == null) {
|
|
|
+ throw exception(ErrorCodeConstants.CCB_MERCHANT_NOT_FOUND);
|
|
|
+ }
|
|
|
+ // 1. 已支付账单不允许重复发起支付(payStatus:0未缴费 1已缴费 2部分缴费)
|
|
|
+ if (expenseOrder.getPayStatus() != null && expenseOrder.getPayStatus().equals(BooleanEnum.TRUE.getValue())) {
|
|
|
+ throw exception(ErrorCodeConstants.EXPENSE_ORDER_ALREADY_PAY);
|
|
|
+ }
|
|
|
+ // 2. 支付金额以账单应收金额为准(回调同样按应收金额校验,避免用户付款成功但回调金额校验不通过)
|
|
|
+ BigDecimal amount = expenseOrder.getActualAmount();
|
|
|
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
|
+ throw exception(ErrorCodeConstants.EXPENSE_ORDER_PAY_AMOUNT_INVALID);
|
|
|
+ }
|
|
|
+ if (req.getAmount() != null && req.getAmount().compareTo(amount) != 0) {
|
|
|
+ throw exception(ErrorCodeConstants.EXPENSE_ORDER_PAY_AMOUNT_NOT_MATCH);
|
|
|
+ }
|
|
|
+ // 2.1 建行 PAYMENT 为 NUMBER(16,2),统一格式化为两位小数(MAC 摘要同步使用该值,避免 8 位小数被建行拒绝)
|
|
|
+ BigDecimal payAmount = amount.setScale(2, RoundingMode.HALF_UP);
|
|
|
+
|
|
|
+ // 3. 查询用户的微信小程序 openId
|
|
|
ResponseEntity<String> response = restTemplate.getForEntity(
|
|
|
"http://home.ynims.com/api/wechat/getOpenIdsByUserPhone?userPhone={userPhone}", String.class, req.getUserPhone());
|
|
|
-
|
|
|
log.info("查询用户openId结果:{}", response.getBody());
|
|
|
|
|
|
ObjectMapper objectMapper = new ObjectMapper();
|
|
|
@@ -78,40 +99,27 @@ public class CcbWxUnifiedOrderController {
|
|
|
String miniAppId = dataNode.path("miniAppId").asText();
|
|
|
String miniOpenId = dataNode.path("miniOpenId").asText();
|
|
|
|
|
|
- // //测试阶段金额设置为0.01
|
|
|
-// amount = new BigDecimal("0.01");
|
|
|
-
|
|
|
- // 1. 构建请求对象(赋值必传+可选字段,空值默认"")
|
|
|
- CcbPayRequest request = new CcbPayRequest();
|
|
|
-// request.setMerchantId(CcbPayConstant.MERCHANTID);
|
|
|
- request.setMerchantId(merchantRecordDO.getMerchantNumber());
|
|
|
-// request.setPosId(CcbPayConstant.POSID);
|
|
|
- request.setPosId(merchantRecordDO.getCounterNumber());
|
|
|
- request.setBranchId(CcbPayConstant.BRANCHID);
|
|
|
- request.setOrderId(expenseOrder.getBillOrderNumber());
|
|
|
- request.setPayment(req.getAmount());
|
|
|
- request.setTradeType("MINIPRO");
|
|
|
- request.setSubAppId(miniAppId);
|
|
|
- request.setSubOpenId(miniOpenId);
|
|
|
- request.setPub(CcbPayConstant.CCB_PUB);
|
|
|
- request.setRemark1("JTSH:"+CcbPayConstant.MERCHANTID+CcbPayConstant.POSID);
|
|
|
- // 2. 生成MAC值
|
|
|
- request.setMac(CcbPayUtils.generateMac(request));
|
|
|
-
|
|
|
- // 3. 发送请求并解析响应
|
|
|
- String respStr = CcbPayUtils.sendRequest(request);
|
|
|
- JSONObject jsonObject = JSON.parseObject(respStr);
|
|
|
-
|
|
|
- // 4. 校验业务结果并发起请求微信参数
|
|
|
-
|
|
|
- if ("true".equals(jsonObject.getString("SUCCESS"))) {
|
|
|
- String payUrl = jsonObject.getString("PAYURL");
|
|
|
- try {
|
|
|
- wxPayParams = CcbPayUtils.requestPayUrl(payUrl);
|
|
|
- } catch (Exception e) {
|
|
|
- log.error("请求微信参数异常 {}", e.getMessage());
|
|
|
+ // 4. 同一订单加进程内锁,防止并发重复下单占用建行侧订单号
|
|
|
+ synchronized (getOrderLock(expenseOrder.getBillOrderNumber())) {
|
|
|
+ String baseOrderId = expenseOrder.getBillOrderNumber();
|
|
|
+ // 4.1 优先使用账单号下单,与回调、对账口径保持一致
|
|
|
+ wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, baseOrderId);
|
|
|
+ // 4.2 订单号被占用:先尝试关闭建行侧残留订单,成功后仍用原订单号下单(不产生额外订单号)
|
|
|
+ if (isOrderIdConflict(wxPayParams)) {
|
|
|
+ log.warn("建行下单失败,订单号:{},错误码:{},错误信息:{},尝试关闭建行侧残留订单后重新下单",
|
|
|
+ baseOrderId, wxPayParams.getErrCode(), wxPayParams.getErrMsg());
|
|
|
+ JSONObject closeResp = closeCcbOrder(expenseOrder, merchantRecordDO, miniAppId, false);
|
|
|
+ if (closeResp != null) {
|
|
|
+ wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, baseOrderId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 4.3 关单未成功(如建行关单接口不可用)时,换全新支付订单号重试,绕开被占用的订单号
|
|
|
+ for (int seq = 1; seq <= MAX_NEW_ORDER_RETRY && isOrderIdConflict(wxPayParams); seq++) {
|
|
|
+ String newOrderId = baseOrderId + PAY_ORDER_SEQ_PREFIX + seq;
|
|
|
+ log.warn("建行下单失败,订单号:{},错误码:{},错误信息:{},改用新支付订单号[{}]重试",
|
|
|
+ baseOrderId, wxPayParams.getErrCode(), wxPayParams.getErrMsg(), newOrderId);
|
|
|
+ wxPayParams = doUnifiedOrder(expenseOrder, merchantRecordDO, payAmount, miniAppId, miniOpenId, newOrderId);
|
|
|
}
|
|
|
-
|
|
|
}
|
|
|
} else {
|
|
|
// 处理错误情况
|
|
|
@@ -168,7 +176,8 @@ public class CcbWxUnifiedOrderController {
|
|
|
@PermitAll
|
|
|
public CommonResult<JSONObject> closeOrder(
|
|
|
@RequestParam String orderId,
|
|
|
- @RequestParam(required = false) String subAppId) throws Exception {
|
|
|
+ @RequestParam(required = false) String subAppId,
|
|
|
+ @RequestParam(required = false) String txCode) throws Exception {
|
|
|
// 传回来的就是智慧系统的订单编号
|
|
|
ExpenseOrderDO expenseOrder = expenseOrderMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderDO>()
|
|
|
.eq(ExpenseOrderDO::getBillOrderNumber,orderId));
|
|
|
@@ -182,6 +191,8 @@ public class CcbWxUnifiedOrderController {
|
|
|
request.setBRANCHID(CcbPayConstant.BRANCHID);
|
|
|
request.setORDERID(orderId);
|
|
|
request.setSUB_APPID(subAppId); // 可选字段,可为null
|
|
|
+ // 关单交易码,可通过参数临时覆盖(用于排查交易码是否正确,MAC校验失败可能由交易码不匹配导致)
|
|
|
+ request.setTXCODE(txCode == null || txCode.isEmpty() ? CcbPayConstant.CLOSE_TXCODE : txCode);
|
|
|
|
|
|
|
|
|
String mac = CcbPayUtils.generateCloseOrderMac(request);
|
|
|
@@ -202,4 +213,148 @@ public class CcbWxUnifiedOrderController {
|
|
|
}
|
|
|
return CommonResult.success(response);
|
|
|
}
|
|
|
+
|
|
|
+ // ==================== 下单辅助方法 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 执行建行微信统一下单:构建请求参数、生成MAC、发送下单请求、成功后请求PAYURL获取微信支付参数。
|
|
|
+ * 任何异常均封装进返回值,不向上抛出,由调用方决定是否换号/关单重试。
|
|
|
+ *
|
|
|
+ * @param payOrderId 实际提交给建行的商户订单号(可能与账单号不同,见 PAY_ORDER_SEQ_PREFIX)
|
|
|
+ */
|
|
|
+ private WxPayParams doUnifiedOrder(ExpenseOrderDO expenseOrder, MerchantRecordDO merchantRecordDO,
|
|
|
+ BigDecimal amount, String miniAppId, String miniOpenId, String payOrderId) {
|
|
|
+ try {
|
|
|
+ // 1. 构建请求对象(赋值必传+可选字段,空值默认"")
|
|
|
+ CcbPayRequest request = new CcbPayRequest();
|
|
|
+ request.setMerchantId(merchantRecordDO.getMerchantNumber());
|
|
|
+ request.setPosId(merchantRecordDO.getCounterNumber());
|
|
|
+ request.setBranchId(CcbPayConstant.BRANCHID);
|
|
|
+ request.setOrderId(payOrderId);
|
|
|
+ request.setPayment(amount);
|
|
|
+ request.setTradeType("MINIPRO");
|
|
|
+ request.setSubAppId(miniAppId);
|
|
|
+ request.setSubOpenId(miniOpenId);
|
|
|
+ request.setPub(CcbPayConstant.CCB_PUB);
|
|
|
+ request.setRemark1("JTSH:" + CcbPayConstant.MERCHANTID + CcbPayConstant.POSID);
|
|
|
+
|
|
|
+ // 2. 生成MAC值
|
|
|
+ request.setMac(CcbPayUtils.generateMac(request));
|
|
|
+
|
|
|
+ // 3. 发送请求并解析响应
|
|
|
+ String respStr = CcbPayUtils.sendRequest(request);
|
|
|
+ JSONObject jsonObject = JSON.parseObject(respStr);
|
|
|
+ WxPayParams wxPayParams = new WxPayParams();
|
|
|
+
|
|
|
+ // 4. 校验业务结果并发起请求微信参数
|
|
|
+ if ("true".equals(jsonObject.getString("SUCCESS"))) {
|
|
|
+ String payUrl = jsonObject.getString("PAYURL");
|
|
|
+ wxPayParams = CcbPayUtils.requestPayUrl(payUrl);
|
|
|
+ } else {
|
|
|
+ // 建行返回业务失败(SUCCESS=false),回填错误码/错误信息
|
|
|
+ wxPayParams.setSuccess(jsonObject.getString("SUCCESS"));
|
|
|
+ wxPayParams.setErrCode(jsonObject.getString("ERRCODE"));
|
|
|
+ wxPayParams.setErrMsg(jsonObject.getString("ERRMSG"));
|
|
|
+ }
|
|
|
+ return wxPayParams;
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 解析异常(如建行返回 HTML 错误页)、网络异常等统一封装,避免直接报错
|
|
|
+ log.error("建行下单异常,订单号:{}", payOrderId, e);
|
|
|
+ WxPayParams wxPayParams = new WxPayParams();
|
|
|
+ wxPayParams.setSuccess("false");
|
|
|
+ wxPayParams.setErrCode("EXCEPTION");
|
|
|
+ String message = e.getMessage();
|
|
|
+ // 建行异常时可能返回整页 HTML,截取前500字符便于定位
|
|
|
+ if (message != null && message.length() > 500) {
|
|
|
+ message = message.substring(0, 500);
|
|
|
+ }
|
|
|
+ wxPayParams.setErrMsg("建行下单异常:" + message);
|
|
|
+ return wxPayParams;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断建行统一下单是否成功(需同时满足:SUCCESS=true、错误码 ERRCODE=000000 且已返回 prepayid)
|
|
|
+ * 注意:建行在“商户订单号重复”等场景会返回 SUCCESS=true 但 ERRCODE 为业务错误码(如 ZBLAP101WX01),
|
|
|
+ * 此时并未真正拿到微信支付参数,必须视为下单失败走关单重试,否则前端会拿到空参数导致调起支付失败。
|
|
|
+ */
|
|
|
+ private boolean isUnifiedOrderSuccess(WxPayParams wxPayParams) {
|
|
|
+ return wxPayParams != null
|
|
|
+ && "true".equals(wxPayParams.getSuccess())
|
|
|
+ && "000000".equals(wxPayParams.getErrCode())
|
|
|
+ && wxPayParams.getPrepayId() != null
|
|
|
+ && !wxPayParams.getPrepayId().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断下单失败是否由建行侧订单号重复(残留订单占用)导致。
|
|
|
+ * 只有这类失败才适合换支付订单号重试,其他异常换号既无意义又会产生多余的支付订单。
|
|
|
+ */
|
|
|
+ private boolean isOrderIdConflict(WxPayParams wxPayParams) {
|
|
|
+ if (wxPayParams == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String errCode = wxPayParams.getErrCode();
|
|
|
+ String errMsg = wxPayParams.getErrMsg();
|
|
|
+ return (errCode != null && errCode.startsWith("ZBLAP101"))
|
|
|
+ || (errMsg != null && errMsg.contains("订单号重复"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 关闭建行侧订单
|
|
|
+ *
|
|
|
+ * @param throwIfFail 关闭失败时是否向上抛异常
|
|
|
+ * @return 关闭成功返回建行响应JSON;关闭失败且不抛异常时返回null
|
|
|
+ */
|
|
|
+ private JSONObject closeCcbOrder(ExpenseOrderDO expenseOrder, MerchantRecordDO merchantRecordDO,
|
|
|
+ String subAppId, boolean throwIfFail) throws Exception {
|
|
|
+ try {
|
|
|
+ CcbCloseOrderRequestVO request = new CcbCloseOrderRequestVO();
|
|
|
+ request.setMERCHANTID(merchantRecordDO.getMerchantNumber());
|
|
|
+ request.setPOSID(merchantRecordDO.getCounterNumber());
|
|
|
+ request.setBRANCHID(CcbPayConstant.BRANCHID);
|
|
|
+ request.setORDERID(expenseOrder.getBillOrderNumber());
|
|
|
+ request.setSUB_APPID(subAppId); // 可选字段,可为null
|
|
|
+ request.setTXCODE(CcbPayConstant.CLOSE_TXCODE);
|
|
|
+
|
|
|
+ String mac = CcbPayUtils.generateCloseOrderMac(request);
|
|
|
+ request.setMAC(mac);
|
|
|
+ log.info("关闭订单生成MAC:{},订单号:{}", mac, expenseOrder.getBillOrderNumber());
|
|
|
+
|
|
|
+ String responseStr = CcbPayUtils.sendCloseOrderRequest(request);
|
|
|
+ JSONObject response = JSONObject.parseObject(responseStr);
|
|
|
+
|
|
|
+ // 业务结果日志(ERRCODE=000000表示关闭成功)
|
|
|
+ if ("000000".equals(response.get("ERRCODE"))) {
|
|
|
+ log.info("订单关闭成功,订单号:{},响应信息:{}", expenseOrder.getBillOrderNumber(), response.get("ERRMSG"));
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+ log.error("订单关闭失败,订单号:{},错误码:{},错误信息:{}",
|
|
|
+ expenseOrder.getBillOrderNumber(), response.get("ERRCODE"), response.get("ERRMSG"));
|
|
|
+ if (throwIfFail) {
|
|
|
+ throw new Exception(String.format("订单关闭失败:错误码[%s],错误信息[%s]",
|
|
|
+ response.get("ERRCODE"), response.get("ERRMSG")));
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("关闭订单异常,订单号:{}", expenseOrder.getBillOrderNumber(), e);
|
|
|
+ if (throwIfFail) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 订单级锁,防止同一订单并发重复下单(进程内锁) */
|
|
|
+ private static final ConcurrentHashMap<String, Object> ORDER_LOCKS = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ /** 建行侧订单号被残留订单占用时,换支付订单号重试的最大次数(订单号形如:原单号R1、R2...) */
|
|
|
+ private static final int MAX_NEW_ORDER_RETRY = 5;
|
|
|
+
|
|
|
+ /** 换号重试的序号前缀,回调侧会按该前缀还原真实账单号 */
|
|
|
+ private static final String PAY_ORDER_SEQ_PREFIX = "R";
|
|
|
+
|
|
|
+ private Object getOrderLock(String billOrderNumber) {
|
|
|
+ return ORDER_LOCKS.computeIfAbsent(billOrderNumber, key -> new Object());
|
|
|
+ }
|
|
|
}
|