diff --git a/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/PayController.java b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/PayController.java index 9879e89f3..d374086e6 100644 --- a/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/PayController.java +++ b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/PayController.java @@ -8,6 +8,7 @@ import com.jsowell.common.constant.Constants; import com.jsowell.common.core.controller.BaseController; import com.jsowell.common.core.redis.RedisCache; import com.jsowell.common.enums.adapay.AdapayPayChannelEnum; +import com.jsowell.common.enums.ykc.OrderPayModeEnum; import com.jsowell.common.enums.ykc.ReturnCodeEnum; import com.jsowell.common.exception.BusinessException; import com.jsowell.common.response.RestApiResponse; @@ -125,7 +126,8 @@ public class PayController extends BaseController { if (StringUtils.isBlank(memberId)) { throw new BusinessException(ReturnCodeEnum.CODE_TOKEN_ERROR); } - if (dto.getPayAmount() == null) { + if (dto.getPayAmount() == null + && !StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), dto.getPayMode())) { throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR); } dto.setMemberId(memberId); diff --git a/jsowell-admin/src/test/java/com/jsowell/pile/util/ChargeAmountUtilsTest.java b/jsowell-admin/src/test/java/com/jsowell/pile/util/ChargeAmountUtilsTest.java new file mode 100644 index 000000000..63ea0bd85 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/pile/util/ChargeAmountUtilsTest.java @@ -0,0 +1,33 @@ +package com.jsowell.pile.util; + +import com.jsowell.common.constant.Constants; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ChargeAmountUtilsTest { + + @Test + void resolveStartupAmount_shouldPreferFrontendAmount() { + BigDecimal amount = ChargeAmountUtils.resolveStartupAmount( + new BigDecimal("100"), new BigDecimal("300")); + + assertEquals(new BigDecimal("100"), amount); + } + + @Test + void resolveStartupAmount_shouldUseMemberDefaultWhenFrontendAmountIsNull() { + BigDecimal amount = ChargeAmountUtils.resolveStartupAmount(null, new BigDecimal("300")); + + assertEquals(new BigDecimal("300"), amount); + } + + @Test + void resolveStartupAmount_shouldUseCodeDefaultWhenOtherAmountsAreUnavailable() { + BigDecimal amount = ChargeAmountUtils.resolveStartupAmount(null, BigDecimal.ZERO); + + assertEquals(Constants.BALANCE_PAY_MAX_AMOUNT_NEW, amount); + } +} diff --git a/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java b/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java index 782d37e69..14d29d455 100644 --- a/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java +++ b/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java @@ -78,6 +78,15 @@ public class CacheConstants { // 余额支付订单退款锁 public static final String ORDER_BALANCE_PAYMENT_REFUND_LOCK = "order_balance_payment_refund_lock:"; + // 订单支付凭证已校验标记 + public static final String ORDER_PAYMENT_READY = "order_payment_ready:"; + + // 订单支付异常补偿锁 + public static final String ORDER_PAYMENT_RECONCILE_LOCK = "order_payment_reconcile_lock:"; + + // 订单支付异常停机冷却标记 + public static final String ORDER_PAYMENT_ANOMALY_STOP = "order_payment_anomaly_stop:"; + // 平台测试员 public static final String PLATFORM_TESTER = "platform_tester:"; diff --git a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/ConfirmStartChargingRequestHandler.java b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/ConfirmStartChargingRequestHandler.java index c3dcef7ec..855ce3f88 100644 --- a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/ConfirmStartChargingRequestHandler.java +++ b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/ConfirmStartChargingRequestHandler.java @@ -6,29 +6,28 @@ import com.jsowell.common.constant.Constants; import com.jsowell.common.core.domain.ykc.YKCDataProtocol; import com.jsowell.common.core.domain.ykc.YKCFrameTypeCode; import com.jsowell.common.enums.ykc.OrderPayModeEnum; +import com.jsowell.common.enums.ykc.ReturnCodeEnum; +import com.jsowell.common.exception.BusinessException; import com.jsowell.common.util.BytesUtil; import com.jsowell.common.util.StringUtils; import com.jsowell.common.util.YKCUtils; import com.jsowell.common.util.id.IdUtils; -import com.jsowell.common.util.spring.SpringUtils; import com.jsowell.netty.factory.YKCOperateFactory; import com.jsowell.pile.domain.PileBasicInfo; import com.jsowell.pile.dto.ConfirmStartChargingData; import com.jsowell.pile.dto.GenerateOrderDTO; import com.jsowell.pile.dto.SavePileMsgDTO; import com.jsowell.pile.service.*; +import com.jsowell.pile.util.ChargeAmountUtils; import com.jsowell.pile.vo.base.ConfirmStartChargingMemberVO; import io.netty.channel.ChannelHandlerContext; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.CompletableFuture; /** * 充电桩主动申请启动充电 0x31 @@ -42,18 +41,9 @@ import java.util.concurrent.CompletableFuture; public class ConfirmStartChargingRequestHandler extends AbstractYkcHandler { private final String type = YKCUtils.frameType2Str(YKCFrameTypeCode.REQUEST_START_CHARGING_CODE.getBytes()); - // 引入线程池 - private ThreadPoolTaskExecutor executor = SpringUtils.getBean("threadPoolTaskExecutor"); - - @Autowired - private PileAuthCardService pileAuthCardService; - @Autowired private OrderBasicInfoService orderBasicInfoService; - @Autowired - private MemberPlateNumberRelationService memberPlateNumberRelationService; - @Autowired private PileMsgRecordService pileMsgRecordService; @@ -198,8 +188,14 @@ public class ConfirmStartChargingRequestHandler extends AbstractYkcHandler { .vinCode(vinCode) .build(); - // 启动充电 - Map resultMap = this.confirmStartCharging(confirmStartChargingData); + Map resultMap; + try { + // 启动充电 + resultMap = this.confirmStartCharging(confirmStartChargingData); + } catch (Exception e) { + log.error("充电桩主动申请充电处理失败, param:{}", JSON.toJSONString(confirmStartChargingData), e); + resultMap = buildFailureResult("05"); + } byte[] defeatReasonByteArr = Constants.zeroByteArray; /** @@ -279,6 +275,10 @@ public class ConfirmStartChargingRequestHandler extends AbstractYkcHandler { // 查询充电桩信息 PileBasicInfo pileBasicInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileSn); + if (pileBasicInfo == null) { + log.info("充电桩主动申请充电未查询到桩信息, pileSn:{}", pileSn); + return buildFailureResult("05"); + } // 查询是否有权限 ConfirmStartChargingMemberVO memberWalletVO = null; @@ -295,50 +295,38 @@ public class ConfirmStartChargingRequestHandler extends AbstractYkcHandler { log.info("根据vin码:{},查询会员信息:{}", vinCode, JSON.toJSONString(memberWalletVO)); } - String accountBalance = Constants.ZERO; - // 如果查询后memberWalletVO为null, 则标示无权限 if (memberWalletVO == null) { - resultMap.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); - resultMap.put("accountBalance", accountBalance); - resultMap.put("defeatReasonCode", "01"); log.info("无此会员权限"); - return resultMap; - } - - // 如果不是平台测试员, 并且也不是站点白名单, 并且总余额小于等于0, 则提示余额不足 - if (!StringUtils.equals(Constants.ONE, memberWalletVO.getPlatformTesterFlag()) - && !StringUtils.equals(Constants.ONE, memberWalletVO.getStationWhiteListFlag()) - && memberWalletVO.getTotalBalance().compareTo(BigDecimal.ZERO) <= 0) { - resultMap.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); - resultMap.put("accountBalance", accountBalance); - resultMap.put("defeatReasonCode", "03"); - log.info("余额不足"); - return resultMap; + return buildFailureResult("01"); } /* 鉴权通过, 生成订单并支付 */ + BigDecimal chargeAmount; String payMode; if (StringUtils.equals(Constants.ONE, memberWalletVO.getPlatformTesterFlag()) || StringUtils.equals(Constants.ONE, memberWalletVO.getStationWhiteListFlag())) { // 如果是平台测试员或者是站点白名单 - accountBalance = Constants.WHITELIST_DEFAULT_AMOUNT.toString(); + chargeAmount = Constants.WHITELIST_DEFAULT_AMOUNT; payMode = OrderPayModeEnum.PAYMENT_OF_WHITELIST.getValue(); } else { - BigDecimal totalBalance = memberWalletVO.getTotalBalance(); - accountBalance = totalBalance.toString(); - if (totalBalance.compareTo(Constants.BALANCE_PAY_MAX_AMOUNT_NEW) > 0) { - accountBalance = Constants.BALANCE_PAY_MAX_AMOUNT_NEW.toString(); + chargeAmount = ChargeAmountUtils.resolveStartupAmount( + dto.getChargeAmount(), memberWalletVO.getDefaultChargeAmount()); + BigDecimal totalBalance = memberWalletVO.getTotalBalance() == null + ? BigDecimal.ZERO + : memberWalletVO.getTotalBalance(); + if (!ChargeAmountUtils.isPositive(chargeAmount) || totalBalance.compareTo(chargeAmount) < 0) { + log.info("余额不足, memberId:{}, chargeAmount:{}, totalBalance:{}", + memberWalletVO.getMemberId(), chargeAmount, totalBalance); + return buildFailureResult("03"); } payMode = OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(); } // 生成交易流水号, 传递给订单使用 String transactionCode = IdUtils.generateTransactionCode(pileSn, connectorCode); - resultMap.put("transactionCode", transactionCode); - resultMap.put("accountBalance", accountBalance); dto.setPileSn(pileSn); if (StringUtils.isNotBlank(memberWalletVO.getPlateNumber())) { @@ -346,34 +334,40 @@ public class ConfirmStartChargingRequestHandler extends AbstractYkcHandler { } dto.setConnectorCode(connectorCode); dto.setTransactionCode(transactionCode); - if (memberWalletVO.getDefaultChargeAmount() != null && memberWalletVO.getDefaultChargeAmount().compareTo(BigDecimal.ZERO) > 0) { - dto.setChargeAmount(memberWalletVO.getDefaultChargeAmount()); - }else { - dto.setChargeAmount(new BigDecimal(accountBalance)); - } + dto.setChargeAmount(chargeAmount); dto.setPayMode(payMode); dto.setStartMode(StringUtils.equals("01", startMode) ? "2" : "5"); dto.setMemberId(memberWalletVO.getMemberId()); dto.setMerchantId(pileBasicInfo.getMerchantId() + ""); - // 校验充电桩相关的信息 try { - orderBasicInfoService.checkPileInfoForEV(dto); + // 必须等订单创建并完成余额支付后,才能向桩端返回鉴权成功。 + Map map = orderBasicInfoService.generateOrderByCardV2(dto); + if (map == null) { + throw new BusinessException(ReturnCodeEnum.CODE_GENERATE_ORDER_ERROR); + } + } catch (BusinessException e) { + String defeatReasonCode = StringUtils.equals( + ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT.getValue(), e.getCode()) ? "03" : "05"; + log.error("充电桩主动申请充电生成订单失败, memberId:{}, chargeAmount:{}", + memberWalletVO.getMemberId(), chargeAmount, e); + return buildFailureResult(defeatReasonCode); } catch (Exception e) { - // 校验充电桩报错,返回校验值 05-桩停用 - resultMap.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); - resultMap.put("accountBalance", accountBalance); - resultMap.put("defeatReasonCode", "05"); + log.error("充电桩主动申请充电生成订单失败, memberId:{}, chargeAmount:{}", + memberWalletVO.getMemberId(), chargeAmount, e); + return buildFailureResult("05"); } - // 异步创建订单 - CompletableFuture.runAsync(() -> { - try { - Map map = orderBasicInfoService.generateOrderByCardV2(dto); - } catch (Exception e) { - throw new RuntimeException(e); - } - }, executor); + resultMap.put("transactionCode", transactionCode); + resultMap.put("accountBalance", chargeAmount.toString()); + return resultMap; + } + + private Map buildFailureResult(String defeatReasonCode) { + Map resultMap = new HashMap<>(); + resultMap.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); + resultMap.put("accountBalance", Constants.ZERO); + resultMap.put("defeatReasonCode", defeatReasonCode); return resultMap; } diff --git a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/PileApplyMergeChargeHandler.java b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/PileApplyMergeChargeHandler.java index debe0f658..9e59c191a 100644 --- a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/PileApplyMergeChargeHandler.java +++ b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/PileApplyMergeChargeHandler.java @@ -138,6 +138,9 @@ public class PileApplyMergeChargeHandler extends AbstractYkcHandler{ log.info("桩号:{}, 并充订单鉴权成功, 结果map:{}", pileSn, map); } catch (Exception e) { log.error("桩号:{}, 并充订单鉴权失败, ", pileSn, e); + map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); + map.put("accountBalance", Constants.ZERO); + map.put("defeatReasonCode", "05"); } String transactionCode = map.get("transactionCode"); // 如果交易流水号不等于Constants.ILLEGAL_TRANSACTION_CODE, 表示鉴权通过 diff --git a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/UploadRealTimeMonitorHandler.java b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/UploadRealTimeMonitorHandler.java index f7ace0826..494f080b8 100644 --- a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/UploadRealTimeMonitorHandler.java +++ b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/UploadRealTimeMonitorHandler.java @@ -19,6 +19,7 @@ import com.jsowell.netty.factory.YKCOperateFactory; import com.jsowell.pile.domain.OrderBasicInfo; import com.jsowell.pile.service.OrderBasicInfoService; import com.jsowell.pile.service.PileBasicInfoService; +import com.jsowell.pile.vo.web.OrderPaymentCheckResult; import com.jsowell.thirdparty.common.CommonService; import io.netty.channel.ChannelHandlerContext; import lombok.extern.slf4j.Slf4j; @@ -297,6 +298,22 @@ public class UploadRealTimeMonitorHandler extends AbstractYkcHandler { // 查询数据库中该订单当前信息 OrderBasicInfo orderInfo = orderBasicInfoService.getOrderInfoByTransactionCode(transactionCode); if (Objects.nonNull(orderInfo)) { + OrderPaymentCheckResult paymentCheck = orderBasicInfoService.checkAndRepairOrderPayment(orderInfo); + boolean paymentReady = paymentCheck.isOrderPaymentReady(); + if (!paymentCheck.isCheckCompleted()) { + // 查询依赖暂时异常时只保留实时数据,等待后续补偿重试,不能据此判定订单未支付。 + saveRedisFlag = true; + log.error("实时监控支付凭证校验未完成,保留实时数据且不变更订单状态, orderCode:{}, reason:{}", + orderInfo.getOrderCode(), paymentCheck.getReason()); + } else if (!paymentReady) { + // 实时监控只能保存原始数据并隔离订单,不能把待支付订单改成已支付。 + saveRedisFlag = true; + orderBasicInfoService.handleOrderPaymentAnomaly(orderInfo, + paymentCheck.getReason(), null, true); + log.warn("实时监控发现支付凭证无效, orderCode:{}, reason:{}", + orderInfo.getOrderCode(), paymentCheck.getReason()); + } + if (paymentCheck.isCheckCompleted() && paymentReady) { // 保存此订单的soc YKCUtils.saveSOC(transactionCode, realTimeMonitorData.getSOC()); @@ -315,11 +332,6 @@ public class UploadRealTimeMonitorHandler extends AbstractYkcHandler { orderInfo.setOrderStatus(OrderStatusEnum.IN_THE_CHARGING.getValue()); } - if (StringUtils.equals(orderInfo.getPayStatus(), OrderPayStatusEnum.unpaid.getValue())) { - // 如果发现该订单的支付状态为 0-待支付,将该订单支付状态改为 1-支付完成 - orderInfo.setPayStatus(OrderPayStatusEnum.paid.getValue()); - } - // 如果原来没有开始充电时间就保存当前时间为开始充电时间 if (orderInfo.getChargeStartTime() == null) { updateFlag = true; @@ -365,6 +377,7 @@ public class UploadRealTimeMonitorHandler extends AbstractYkcHandler { log.error("异步绑定车辆停车优惠券error", e); } }, executor); + } } // 充电时保存实时数据到redis diff --git a/jsowell-netty/src/main/java/com/jsowell/netty/service/rabbitmq/PileRabbitListener.java b/jsowell-netty/src/main/java/com/jsowell/netty/service/rabbitmq/PileRabbitListener.java index 60f161393..67d289af3 100644 --- a/jsowell-netty/src/main/java/com/jsowell/netty/service/rabbitmq/PileRabbitListener.java +++ b/jsowell-netty/src/main/java/com/jsowell/netty/service/rabbitmq/PileRabbitListener.java @@ -30,6 +30,7 @@ import com.jsowell.pile.service.PileBasicInfoService; import com.jsowell.pile.service.PileMsgRecordService; import com.jsowell.pile.service.YKCPushCommandService; import com.jsowell.thirdparty.common.CommonService; +import com.jsowell.pile.vo.web.OrderPaymentCheckResult; import com.rabbitmq.client.Channel; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; @@ -358,6 +359,22 @@ public class PileRabbitListener { // 查询数据库中该订单当前信息 OrderBasicInfo orderInfo = orderBasicInfoService.getOrderInfoByTransactionCode(transactionCode); if (Objects.nonNull(orderInfo)) { + OrderPaymentCheckResult paymentCheck = orderBasicInfoService.checkAndRepairOrderPayment(orderInfo); + boolean paymentReady = paymentCheck.isOrderPaymentReady(); + if (!paymentCheck.isCheckCompleted()) { + // 查询依赖暂时异常时只保留实时数据,等待后续补偿重试,不能据此判定订单未支付。 + saveRedisFlag = true; + log.error("实时监控支付凭证校验未完成,保留实时数据且不变更订单状态, orderCode:{}, reason:{}", + orderInfo.getOrderCode(), paymentCheck.getReason()); + } else if (!paymentReady) { + // 实时监控不能把待支付订单伪造成已支付,只保留实时数据并执行幂等停机。 + saveRedisFlag = true; + orderBasicInfoService.handleOrderPaymentAnomaly(orderInfo, + paymentCheck.getReason(), null, true); + log.warn("实时监控发现支付凭证无效, orderCode:{}, reason:{}", + orderInfo.getOrderCode(), paymentCheck.getReason()); + } + if (paymentCheck.isCheckCompleted() && paymentReady) { if (StringUtils.equals(orderInfo.getOrderStatus(), OrderStatusEnum.ORDER_COMPLETE.getValue()) || StringUtils.equals(orderInfo.getOrderStatus(), OrderStatusEnum.STAY_SETTLEMENT.getValue())) { // 在订单状态为 订单完成或待结算,不保存 @@ -373,11 +390,6 @@ public class PileRabbitListener { orderInfo.setOrderStatus(OrderStatusEnum.IN_THE_CHARGING.getValue()); } - if (StringUtils.equals(orderInfo.getPayStatus(), OrderPayStatusEnum.unpaid.getValue())) { - // 如果发现该订单的支付状态为 0-待支付,将该订单支付状态改为 1-支付完成 - orderInfo.setPayStatus(OrderPayStatusEnum.paid.getValue()); - } - // 如果原来没有开始充电时间就保存当前时间为开始充电时间 if (orderInfo.getChargeStartTime() == null) { updateFlag = true; @@ -387,6 +399,7 @@ public class PileRabbitListener { if (updateFlag) { orderBasicInfoService.updateOrderBasicInfo(orderInfo); } + } } // 充电时保存实时数据到redis diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java index ab44805da..578387bc5 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java @@ -264,6 +264,12 @@ public interface OrderBasicInfoMapper { @Param("orderStatus") String orderStatus, @Param("payStatus") String payStatus); + /** + * 查询需要进行支付凭证补偿的余额支付订单。 + */ + List queryOrdersForPaymentReconcile(@Param("startTime") String startTime, + @Param("endTime") String endTime); + /** * 个人桩查询充电数据 * diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java index 4d924a481..cdbf58f0f 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java @@ -339,6 +339,33 @@ public interface OrderBasicInfoService{ */ void closeStartFailedOrder(String startTime, String endTime); + /** + * 校验订单支付凭证,并在底层支付流水完整时修复订单主表支付字段。 + * + * @param orderBasicInfo 订单信息 + * @return 支付凭证校验结果 + */ + OrderPaymentCheckResult checkAndRepairOrderPayment(OrderBasicInfo orderBasicInfo); + + /** + * 处理支付凭证异常订单。该方法只会将订单标记为异常,不会走正常结算或退款流程。 + * + * @param orderBasicInfo 订单信息 + * @param reason 异常原因 + * @param data 原始交易记录报文,可为空 + * @param stopCharging 是否需要向充电桩发送停机指令 + */ + void handleOrderPaymentAnomaly(OrderBasicInfo orderBasicInfo, String reason, + TransactionRecordsData data, boolean stopCharging); + + /** + * 扫描并补偿支付凭证异常的余额支付订单。 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + */ + void reconcileChargingOrderPayments(String startTime, String endTime); + List getListByOrderCodes(List orderCodes); /** diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java index 164848e2a..a4e743c9a 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java @@ -54,6 +54,7 @@ import com.jsowell.pile.service.programlogic.ProgramLogicFactory; import com.jsowell.pile.transaction.dto.ClearingBillTransactionDTO; import com.jsowell.pile.transaction.dto.OrderTransactionDTO; import com.jsowell.pile.transaction.service.TransactionService; +import com.jsowell.pile.util.ChargeAmountUtils; import com.jsowell.pile.util.MerchantUtils; import com.jsowell.pile.util.UserUtils; import com.jsowell.pile.vo.OrderInfoDetailVO; @@ -761,6 +762,18 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { } for (OrderBasicInfo orderBasicInfo : orderList) { + OrderPaymentCheckResult paymentCheck = checkAndRepairOrderPayment(orderBasicInfo); + if (!paymentCheck.isCheckCompleted()) { + logger.error("关闭启动失败订单前支付凭证校验未完成,本次跳过, orderCode:{}, reason:{}", + orderBasicInfo.getOrderCode(), paymentCheck.getReason()); + continue; + } + if (!paymentCheck.isOrderPaymentReady()) { + handleOrderPaymentAnomaly(orderBasicInfo, paymentCheck.getReason(), null, false); + logger.warn("关闭启动失败订单前支付凭证校验失败,跳过正常结算退款, orderCode:{}, reason:{}", + orderBasicInfo.getOrderCode(), paymentCheck.getReason()); + continue; + } // 判断支付成功时间距当前时间是否大于15分钟 String startType = orderBasicInfo.getStartType(); // 立即启动的订单使用支付时间判断,预约充电的订单使用预约时间判断 @@ -797,6 +810,428 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { } } + /** + * 校验订单支付凭证,并在支付流水完整、但订单主表字段尚未落库时进行修复。 + * + *

余额支付不能只相信 order_basic_info.pay_status。必须同时存在订单支付明细 + * 和会员钱包出账流水,且两者金额与主表支付金额一致,才允许进入充电或结算流程。

+ */ + @Override + public OrderPaymentCheckResult checkAndRepairOrderPayment(OrderBasicInfo orderBasicInfo) { + if (orderBasicInfo == null || StringUtils.isBlank(orderBasicInfo.getOrderCode())) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(false) + .orderPaymentReady(false) + .reason("订单不存在或订单号为空") + .build(); + } + + OrderPaymentCheckResult result = inspectOrderPayment(orderBasicInfo); + if (!result.isCheckCompleted()) { + return result; + } + if (result.isOrderPaymentReady()) { + redisCache.setCacheObject(CacheConstants.ORDER_PAYMENT_READY + orderBasicInfo.getOrderCode(), + Boolean.TRUE, 10, TimeUnit.MINUTES); + return result; + } + + // 只有底层流水已经形成且金额彼此一致时,才允许修复主表字段。 + if (result.isPaymentEvidenceFound() && result.getPaymentAmount() != null + && result.getPaymentAmount().compareTo(BigDecimal.ZERO) > 0) { + BigDecimal paymentAmount = result.getPaymentAmount(); + Date paymentTime = result.getPaymentTime() == null ? DateUtils.getNowDate() : result.getPaymentTime(); + boolean needUpdate = !StringUtils.equals(OrderPayStatusEnum.paid.getValue(), orderBasicInfo.getPayStatus()) + || orderBasicInfo.getPayAmount() == null + || orderBasicInfo.getPayAmount().compareTo(paymentAmount) != 0 + || orderBasicInfo.getPayTime() == null; + if (needUpdate && orderBasicInfo.getId() != null) { + OrderBasicInfo update = new OrderBasicInfo(); + update.setId(orderBasicInfo.getId()); + update.setOrderCode(orderBasicInfo.getOrderCode()); + update.setTransactionCode(orderBasicInfo.getTransactionCode()); + update.setPayStatus(OrderPayStatusEnum.paid.getValue()); + update.setPayAmount(paymentAmount); + update.setPayTime(paymentTime); + updateOrderBasicInfo(update); + + // 同步更新当前调用链中的对象,避免后续结算仍拿到旧的 null 金额。 + orderBasicInfo.setPayStatus(OrderPayStatusEnum.paid.getValue()); + orderBasicInfo.setPayAmount(paymentAmount); + orderBasicInfo.setPayTime(paymentTime); + } + + // 再次基于修复后的主表字段校验,确保不会把部分流水误判为成功。 + result = inspectOrderPayment(orderBasicInfo); + if (result.isOrderPaymentReady()) { + redisCache.setCacheObject(CacheConstants.ORDER_PAYMENT_READY + orderBasicInfo.getOrderCode(), + Boolean.TRUE, 10, TimeUnit.MINUTES); + } + } + return result; + } + + /** + * 将支付凭证异常订单置为异常,并按幂等规则最多发送一次停机指令。 + * 此处绝不调用正常结算、退款或分账逻辑。 + */ + @Override + public void handleOrderPaymentAnomaly(OrderBasicInfo orderBasicInfo, String reason, + TransactionRecordsData data, boolean stopCharging) { + if (orderBasicInfo == null || StringUtils.isBlank(orderBasicInfo.getOrderCode())) { + return; + } + + String orderCode = orderBasicInfo.getOrderCode(); + String lockKey = CacheConstants.ORDER_PAYMENT_RECONCILE_LOCK + "anomaly:" + orderCode; + String requestId = IdUtils.fastSimpleUUID(); + Boolean locked = redisCache.lock(lockKey, requestId, 30); + if (!Boolean.TRUE.equals(locked)) { + logger.info("支付异常订单正在被其他线程处理, orderCode:{}", orderCode); + return; + } + + OrderBasicInfo latest = null; + boolean shouldSendStop = false; + try { + // 直接查库,避免实时监控线程拿到旧的缓存订单状态。 + latest = orderBasicInfoMapper.getOrderInfoByOrderCode(orderCode); + if (latest == null || StringUtils.equals(OrderStatusEnum.ORDER_COMPLETE.getValue(), latest.getOrderStatus())) { + return; + } + + shouldSendStop = stopCharging && StringUtils.isNotBlank(latest.getPileSn()) + && StringUtils.isNotBlank(latest.getConnectorCode()) + && StringUtils.isNotBlank(latest.getTransactionCode()); + + String anomalyReason = StringUtils.isBlank(reason) ? "订单支付凭证校验失败" : reason; + String normalizedReason = limitOrderReason("支付凭证异常:" + anomalyReason); + Date endTime = data == null ? null : DateUtils.parseDate(data.getEndTime()); + String stopReasonCode = data == null ? null : normalizeStopReasonCode(data.getStopReasonCode()); + boolean needUpdate = !StringUtils.equals(OrderStatusEnum.ABNORMAL.getValue(), latest.getOrderStatus()) + || !StringUtils.equals(normalizedReason, latest.getReason()) + || latest.getSettlementTime() == null + || latest.getChargeEndTime() == null + || (StringUtils.isNotBlank(stopReasonCode) + && !StringUtils.equals(stopReasonCode, latest.getStopReasonCode())); + if (needUpdate) { + OrderBasicInfo update = new OrderBasicInfo(); + update.setId(latest.getId()); + update.setOrderCode(latest.getOrderCode()); + update.setTransactionCode(latest.getTransactionCode()); + update.setOrderStatus(OrderStatusEnum.ABNORMAL.getValue()); + update.setReason(normalizedReason); + update.setSettlementTime(latest.getSettlementTime() == null ? DateUtils.getNowDate() : latest.getSettlementTime()); + if (latest.getChargeEndTime() == null) { + update.setChargeEndTime(endTime == null ? DateUtils.getNowDate() : endTime); + } + if (StringUtils.isNotBlank(stopReasonCode)) { + update.setStopReasonCode(stopReasonCode); + } + updateOrderBasicInfo(update); + } + + // 保存原始交易报文;服务内部按订单号去重,避免重复结算消息制造重复记录。 + if (data != null) { + savePaymentAnomalyReport(latest, data); + } + + } catch (Exception e) { + logger.error("处理支付凭证异常订单失败, orderCode:{}", orderCode, e); + } finally { + redisCache.unLock(lockKey); + } + + if (shouldSendStop && latest != null) { + String transactionCode = latest.getTransactionCode(); + String stopKey = CacheConstants.ORDER_PAYMENT_ANOMALY_STOP + transactionCode; + Boolean firstStop = redisCache.setnx(stopKey, requestId, 30 * 60); + if (Boolean.TRUE.equals(firstStop)) { + try { + pileRemoteService.remoteStopCharging(latest.getPileSn(), latest.getConnectorCode(), transactionCode); + logger.warn("支付凭证异常订单已发送停机指令, orderCode:{}, transactionCode:{}", orderCode, transactionCode); + } catch (Exception e) { + // 指令未成功下发时释放幂等标记,允许后续实时报文或定时补偿再次尝试停机。 + try { + redisCache.deleteObject(stopKey); + } catch (Exception cacheException) { + logger.error("释放支付异常停机标记失败, orderCode:{}, transactionCode:{}", + orderCode, transactionCode, cacheException); + } + logger.error("支付凭证异常订单发送停机指令失败, orderCode:{}, transactionCode:{}", orderCode, transactionCode, e); + } + } else { + logger.info("支付凭证异常订单已发送过停机指令, orderCode:{}, transactionCode:{}", orderCode, transactionCode); + } + } + } + + /** + * 定时补偿余额支付订单。补偿只修复真实存在的支付凭证;完全没有支付凭证的订单直接置为异常, + * 不会伪造支付状态,也不会进入正常结算退款。 + */ + @Override + public void reconcileChargingOrderPayments(String startTime, String endTime) { + List orderList = orderBasicInfoMapper.queryOrdersForPaymentReconcile(startTime, endTime); + if (CollectionUtils.isEmpty(orderList)) { + logger.info("{}-{}期间没有需要支付凭证补偿的订单", startTime, endTime); + return; + } + logger.info("{}-{}期间,共{}条余额支付订单需要支付凭证补偿", startTime, endTime, orderList.size()); + + for (OrderBasicInfo orderBasicInfo : orderList) { + if (orderBasicInfo == null || StringUtils.isBlank(orderBasicInfo.getOrderCode())) { + continue; + } + String orderCode = orderBasicInfo.getOrderCode(); + String lockKey = CacheConstants.ORDER_PAYMENT_RECONCILE_LOCK + orderCode; + String requestId = IdUtils.fastSimpleUUID(); + Boolean locked = redisCache.lock(lockKey, requestId, 30); + if (!Boolean.TRUE.equals(locked)) { + logger.info("支付凭证补偿订单正在被其他线程处理, orderCode:{}", orderCode); + continue; + } + try { + OrderBasicInfo latest = orderBasicInfoMapper.getOrderInfoByOrderCode(orderCode); + if (latest == null || StringUtils.equals(OrderStatusEnum.ORDER_COMPLETE.getValue(), latest.getOrderStatus())) { + continue; + } + OrderPaymentCheckResult result = checkAndRepairOrderPayment(latest); + if (!result.isCheckCompleted()) { + logger.error("支付凭证补偿校验未完成,本次跳过, orderCode:{}, reason:{}", + orderCode, result.getReason()); + continue; + } + if (!result.isOrderPaymentReady()) { + boolean stopCharging = isOrderStillCharging(latest); + handleOrderPaymentAnomaly(latest, result.getReason(), null, stopCharging); + } + } catch (Exception e) { + logger.error("支付凭证补偿订单处理失败, orderCode:{}", orderCode, e); + } finally { + redisCache.unLock(lockKey); + } + } + } + + private boolean isOrderStillCharging(OrderBasicInfo orderBasicInfo) { + if (orderBasicInfo == null) { + return false; + } + if (StringUtils.equals(OrderStatusEnum.IN_THE_CHARGING.getValue(), orderBasicInfo.getOrderStatus())) { + return true; + } + if (StringUtils.isBlank(orderBasicInfo.getPileSn()) || StringUtils.isBlank(orderBasicInfo.getConnectorCode()) + || StringUtils.isBlank(orderBasicInfo.getTransactionCode())) { + return false; + } + String chargingTransaction = redisCache.getCacheObject( + CacheConstants.PILE_IS_CHARGING + orderBasicInfo.getPileSn() + orderBasicInfo.getConnectorCode()); + return StringUtils.equals(orderBasicInfo.getTransactionCode(), chargingTransaction); + } + + /** + * 读取支付明细、钱包出账流水并完成一致性校验。 + */ + private OrderPaymentCheckResult inspectOrderPayment(OrderBasicInfo orderBasicInfo) { + String payMode = orderBasicInfo.getPayMode(); + String payStatus = orderBasicInfo.getPayStatus(); + if (StringUtils.equals(OrderPayStatusEnum.pay_nothing.getValue(), payStatus)) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(true) + .paymentAmount(orderBasicInfo.getPayAmount()) + .paymentTime(orderBasicInfo.getPayTime()) + .reason("订单无需支付") + .build(); + } + + List payRecords; + List walletLogs = Collections.emptyList(); + try { + payRecords = orderPayRecordService.getOrderPayRecordList(orderBasicInfo.getOrderCode()); + if (payRecords == null) { + payRecords = Collections.emptyList(); + } + if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), payMode)) { + List logs = memberWalletLogService.getOrderRecord( + orderBasicInfo.getOrderCode(), MemberWalletEnum.TYPE_OUT.getValue()); + walletLogs = logs == null ? Collections.emptyList() : logs; + } + } catch (Exception e) { + logger.error("读取订单支付凭证失败, orderCode:{}", orderBasicInfo.getOrderCode(), e); + return OrderPaymentCheckResult.builder() + .checkCompleted(false) + .paymentEvidenceFound(false) + .orderPaymentReady(false) + .reason("读取支付凭证失败") + .build(); + } + + BigDecimal payRecordAmount = BigDecimal.ZERO; + boolean payRecordEvidence = false; + Date paymentTime = null; + for (OrderPayRecord record : payRecords) { + if (record == null || StringUtils.equals(DelFlagEnum.DELETE.getValue(), record.getDelFlag())) { + continue; + } + boolean balanceRecord = StringUtils.equals(OrderPayRecordEnum.PRINCIPAL_BALANCE_PAYMENT.getValue(), record.getPayMode()) + || StringUtils.equals(OrderPayRecordEnum.GIFT_BALANCE_PAYMENT.getValue(), record.getPayMode()); + if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), payMode) && !balanceRecord) { + continue; + } + if (record.getPayAmount() != null && record.getPayAmount().compareTo(BigDecimal.ZERO) > 0) { + payRecordEvidence = true; + payRecordAmount = payRecordAmount.add(record.getPayAmount()); + } + if (record.getCreateTime() != null && (paymentTime == null || record.getCreateTime().after(paymentTime))) { + paymentTime = record.getCreateTime(); + } + } + + BigDecimal walletAmount = BigDecimal.ZERO; + boolean walletEvidence = false; + if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), payMode)) { + for (MemberWalletLog walletLog : walletLogs) { + if (walletLog == null + || !StringUtils.equals(MemberWalletEnum.TYPE_OUT.getValue(), walletLog.getType()) + || !StringUtils.equals(MemberWalletEnum.SUBTYPE_PAYMENT_FOR_ORDER.getValue(), walletLog.getSubType()) + || walletLog.getAmount() == null + || walletLog.getAmount().compareTo(BigDecimal.ZERO) == 0) { + continue; + } + walletEvidence = true; + walletAmount = walletAmount.add(walletLog.getAmount().abs()); + if (walletLog.getCreateTime() != null && (paymentTime == null || walletLog.getCreateTime().after(paymentTime))) { + paymentTime = walletLog.getCreateTime(); + } + } + } + + boolean baseFieldsReady = StringUtils.equals(OrderPayStatusEnum.paid.getValue(), payStatus) + && orderBasicInfo.getPayAmount() != null + && orderBasicInfo.getPayAmount().compareTo(BigDecimal.ZERO) > 0 + && orderBasicInfo.getPayTime() != null; + + if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), payMode)) { + boolean evidenceFound = payRecordEvidence || walletEvidence; + if (!payRecordEvidence || !walletEvidence) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(evidenceFound) + .orderPaymentReady(false) + // 余额支付必须同时具备两类底层凭证;单边流水不能用于自动补齐主表支付状态。 + .paymentAmount(null) + .paymentTime(paymentTime) + .reason("余额支付缺少订单支付明细或会员钱包出账流水") + .build(); + } + if (!samePaymentAmount(payRecordAmount, walletAmount)) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(false) + .paymentTime(paymentTime) + .reason("订单支付明细与会员钱包出账金额不一致") + .build(); + } + if (!baseFieldsReady || !samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount)) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(false) + .paymentAmount(payRecordAmount) + .paymentTime(paymentTime) + .reason("支付流水存在,但订单主表支付字段不完整或金额不一致") + .build(); + } + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(true) + .paymentAmount(payRecordAmount) + .paymentTime(paymentTime) + .reason("余额支付凭证校验通过") + .build(); + } + + // 非余额支付保持兼容:必须有完整主表支付字段;若存在支付明细,则再校验明细金额。 + if (!baseFieldsReady) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(payRecordEvidence) + .orderPaymentReady(false) + .paymentAmount(payRecordEvidence ? payRecordAmount : null) + .paymentTime(paymentTime) + .reason("订单主表支付字段不完整") + .build(); + } + if (payRecordEvidence && !samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount)) { + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(false) + .paymentTime(paymentTime) + .reason("订单支付明细与订单主表支付金额不一致") + .build(); + } + return OrderPaymentCheckResult.builder() + .checkCompleted(true) + .paymentEvidenceFound(true) + .orderPaymentReady(true) + .paymentAmount(orderBasicInfo.getPayAmount()) + .paymentTime(orderBasicInfo.getPayTime()) + .reason("支付凭证校验通过") + .build(); + } + + private boolean samePaymentAmount(BigDecimal first, BigDecimal second) { + return first != null && second != null + && first.setScale(2, RoundingMode.HALF_UP).compareTo(second.setScale(2, RoundingMode.HALF_UP)) == 0; + } + + private String limitOrderReason(String reason) { + if (reason == null) { + return null; + } + // order_basic_info.reason 在历史库中通常为 varchar(255),原始报文另存 order_abnormal_record。 + return reason.length() > 250 ? reason.substring(0, 250) : reason; + } + + private String normalizeStopReasonCode(String stopReasonCode) { + if (StringUtils.isBlank(stopReasonCode) || StringUtils.startsWithIgnoreCase(stopReasonCode, "0x")) { + return stopReasonCode; + } + return "0x" + stopReasonCode; + } + + private void savePaymentAnomalyReport(OrderBasicInfo orderBasicInfo, TransactionRecordsData data) { + try { + OrderAbnormalRecord query = new OrderAbnormalRecord(); + query.setOrderCode(orderBasicInfo.getOrderCode()); + if (CollectionUtils.isNotEmpty(orderAbnormalRecordService.selectOrderAbnormalRecordList(query))) { + return; + } + OrderAbnormalRecord record = new OrderAbnormalRecord(); + BeanUtils.copyBeanProp(record, data); + record.setOrderCode(orderBasicInfo.getOrderCode()); + if (StringUtils.isBlank(record.getPileSn())) { + record.setPileSn(orderBasicInfo.getPileSn()); + } + if (StringUtils.isBlank(record.getConnectorCode())) { + record.setConnectorCode(orderBasicInfo.getConnectorCode()); + } + if (record.getCreateTime() == null) { + record.setCreateTime(DateUtils.getNowDate()); + } + orderAbnormalRecordService.insertOrderAbnormalRecord(record); + } catch (Exception e) { + logger.error("保存支付凭证异常原始交易报文失败, orderCode:{}", orderBasicInfo.getOrderCode(), e); + } + } + /** * 判断是否退款保险金额 * @return @@ -4115,18 +4550,7 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { } else { // 通过memberId获取账户余额 MemberVO memberVO = memberBasicInfoService.queryMemberInfoByMemberId(dto.getMemberId(), merchantId); - if (memberVO == null) { - throw new BusinessException("01", "查询用户账户为空"); - } - accountBalance = memberVO.getTotalBalance(); - if (accountBalance.compareTo(BigDecimal.ZERO) <= 0) { - logger.info("充电桩主动申请启动充电生成订单 余额不足, memberId:{}, MemberVO:{}", dto.getMemberId(), JSON.toJSONString(memberVO)); - throw new BusinessException("03", "用户账户余额不足"); - } - BigDecimal defaultAmount = Constants.BALANCE_PAY_MAX_AMOUNT_NEW; - if (accountBalance.compareTo(defaultAmount) > 0) { - accountBalance = defaultAmount; - } + accountBalance = resolveBalanceChargeAmount(dto.getChargeAmount(), memberVO, dto.getMemberId()); payMode = OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(); } dto.setChargeAmount(accountBalance); @@ -4218,6 +4642,15 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { // 运营商id String merchantId = dto.getMerchantId(); + if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), payMode)) { + MemberVO memberVO = memberBasicInfoService.queryMemberInfoByMemberId(dto.getMemberId(), merchantId); + accountBalance = resolveBalanceChargeAmount(accountBalance, memberVO, dto.getMemberId()); + } else if (StringUtils.equals(OrderPayModeEnum.PAYMENT_OF_WHITELIST.getValue(), payMode)) { + accountBalance = Constants.WHITELIST_DEFAULT_AMOUNT; + } else if (!ChargeAmountUtils.isPositive(accountBalance)) { + throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR); + } + dto.setChargeAmount(accountBalance); dto.setPayMode(payMode); /* @@ -4276,6 +4709,29 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { return map; } + /** + * 确定余额支付的启动金额,并使用当前运营商钱包余额进行校验。 + */ + private BigDecimal resolveBalanceChargeAmount(BigDecimal frontendChargeAmount, MemberVO memberVO, String memberId) { + if (memberVO == null) { + throw new BusinessException("01", "查询用户账户为空"); + } + BigDecimal chargeAmount = ChargeAmountUtils.resolveStartupAmount( + frontendChargeAmount, memberVO.getDefaultChargeAmount()); + if (!ChargeAmountUtils.isPositive(chargeAmount)) { + throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR.getValue(), "启动金额必须大于0"); + } + BigDecimal totalBalance = memberVO.getTotalBalance() == null + ? BigDecimal.ZERO + : memberVO.getTotalBalance(); + if (totalBalance.compareTo(chargeAmount) < 0) { + logger.info("充电桩主动申请启动充电生成订单 余额不足, memberId:{}, chargeAmount:{}, totalBalance:{}", + memberId, chargeAmount, totalBalance); + throw new BusinessException(ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT); + } + return chargeAmount; + } + /** * 联联平台 获取累计数据用 * @@ -6299,49 +6755,36 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { return map; } - // 如果总余额小于0, 则标示余额不足 - // if (memberWalletVO.getTotalBalance().compareTo(BigDecimal.ZERO) <= 0) { - // map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); - // map.put("accountBalance", accountBalance); - // map.put("defeatReasonCode", "03"); - // logger.info("并充生成订单----余额不足"); - // return map; - // } - - // 如果不是平台测试员, 并且也不是站点白名单, 并且总余额小于等于0, 则提示余额不足 - if (!StringUtils.equals(Constants.ONE, memberWalletVO.getPlatformTesterFlag()) - && !StringUtils.equals(Constants.ONE, memberWalletVO.getStationWhiteListFlag()) - && memberWalletVO.getTotalBalance().compareTo(BigDecimal.ZERO) <= 0) { - map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); - map.put("accountBalance", accountBalance); - map.put("defeatReasonCode", "03"); - logger.info("余额不足"); - return map; - } - /* 鉴权通过, 生成订单并支付 */ + BigDecimal chargeAmount; String payMode; if (StringUtils.equals(Constants.ONE, memberWalletVO.getPlatformTesterFlag()) || StringUtils.equals(Constants.ONE, memberWalletVO.getStationWhiteListFlag())) { // 如果是平台测试员或者是站点白名单 - accountBalance = Constants.WHITELIST_DEFAULT_AMOUNT.toString(); + chargeAmount = Constants.WHITELIST_DEFAULT_AMOUNT; payMode = OrderPayModeEnum.PAYMENT_OF_WHITELIST.getValue(); } else { - BigDecimal totalBalance = memberWalletVO.getTotalBalance(); - accountBalance = totalBalance.toString(); - if (totalBalance.compareTo(Constants.BALANCE_PAY_MAX_AMOUNT_NEW) > 0) { - accountBalance = Constants.BALANCE_PAY_MAX_AMOUNT_NEW.toString(); + chargeAmount = ChargeAmountUtils.resolveStartupAmount( + generateOrderDTO.getChargeAmount(), memberWalletVO.getDefaultChargeAmount()); + BigDecimal totalBalance = memberWalletVO.getTotalBalance() == null + ? BigDecimal.ZERO + : memberWalletVO.getTotalBalance(); + if (!ChargeAmountUtils.isPositive(chargeAmount) || totalBalance.compareTo(chargeAmount) < 0) { + map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); + map.put("accountBalance", accountBalance); + map.put("defeatReasonCode", "03"); + logger.info("并充生成订单----余额不足, memberId:{}, chargeAmount:{}, totalBalance:{}", + memberWalletVO.getMemberId(), chargeAmount, totalBalance); + return map; } payMode = OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(); } + accountBalance = chargeAmount.toString(); // 生成交易流水号, 传递给订单使用 String transactionCode = IdUtils.generateTransactionCode(pileSn, connectorCode); - map.put("transactionCode", transactionCode); - map.put("accountBalance", accountBalance); - // 异步创建订单 // GenerateOrderDTO dto = new GenerateOrderDTO(); generateOrderDTO.setPileSn(pileSn); if (StringUtils.isNotBlank(memberWalletVO.getPlateNumber())) { @@ -6350,22 +6793,35 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { generateOrderDTO.setOrderType(OrderTypeEnum.MERGE_CHARGE_ORDER.getValue()); // 并充订单标识 generateOrderDTO.setConnectorCode(connectorCode); generateOrderDTO.setTransactionCode(transactionCode); - generateOrderDTO.setChargeAmount(new BigDecimal(accountBalance)); + generateOrderDTO.setChargeAmount(chargeAmount); generateOrderDTO.setPayMode(payMode); generateOrderDTO.setStartMode(StringUtils.equals("01", startMode) ? "2" : "5"); generateOrderDTO.setMemberId(memberWalletVO.getMemberId()); generateOrderDTO.setMerchantId(pileBasicInfo.getMerchantId() + ""); - // 校验充电桩相关的信息 - checkPileInfoForEV(generateOrderDTO); - - CompletableFuture.runAsync(() -> { - try { - Map resultMap = generateOrderByCardV2(generateOrderDTO); - } catch (Exception e) { - throw new RuntimeException(e); + try { + Map resultMap = generateOrderByCardV2(generateOrderDTO); + if (resultMap == null) { + throw new BusinessException(ReturnCodeEnum.CODE_GENERATE_ORDER_ERROR); } - }, executor); + } catch (BusinessException e) { + map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); + map.put("accountBalance", Constants.ZERO); + map.put("defeatReasonCode", StringUtils.equals( + ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT.getValue(), e.getCode()) ? "03" : "05"); + logger.error("并充生成订单失败, memberId:{}, chargeAmount:{}", + memberWalletVO.getMemberId(), chargeAmount, e); + return map; + } catch (Exception e) { + map.put("transactionCode", Constants.ILLEGAL_TRANSACTION_CODE); + map.put("accountBalance", Constants.ZERO); + map.put("defeatReasonCode", "05"); + logger.error("并充生成订单失败, memberId:{}, chargeAmount:{}", + memberWalletVO.getMemberId(), chargeAmount, e); + return map; + } + map.put("transactionCode", transactionCode); + map.put("accountBalance", accountBalance); return map; } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/AbstractProgramLogic.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/AbstractProgramLogic.java index 6b5c8181f..5f3208ab3 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/AbstractProgramLogic.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/AbstractProgramLogic.java @@ -149,6 +149,30 @@ public abstract class AbstractProgramLogic implements InitializingBean { */ public abstract void settleOrder(TransactionRecordsData data, OrderBasicInfo orderBasicInfo); + /** + * 结算前统一校验支付凭证。支付凭证不完整时只隔离订单,不进入金额计算、退款或分账流程。 + */ + protected boolean ensureOrderPaymentReadyForSettlement(OrderBasicInfo orderBasicInfo, + TransactionRecordsData data) { + if (orderBasicInfo == null) { + return false; + } + OrderPaymentCheckResult paymentCheck = orderBasicInfoService.checkAndRepairOrderPayment(orderBasicInfo); + if (!paymentCheck.isCheckCompleted()) { + logger.error("订单支付凭证校验未完成,本次跳过结算且不变更订单状态, orderCode:{}, reason:{}", + orderBasicInfo.getOrderCode(), paymentCheck.getReason()); + return false; + } + if (!paymentCheck.isOrderPaymentReady()) { + orderBasicInfoService.handleOrderPaymentAnomaly(orderBasicInfo, + paymentCheck.getReason(), data, false); + logger.warn("订单支付凭证校验失败,跳过正常结算, orderCode:{}, reason:{}", + orderBasicInfo.getOrderCode(), paymentCheck.getReason()); + return false; + } + return true; + } + /** * 电单车结算逻辑 * @param message @@ -214,6 +238,9 @@ public abstract class AbstractProgramLogic implements InitializingBean { BigDecimal orderAmount = new BigDecimal(data.getConsumptionAmount()).setScale(2, RoundingMode.HALF_UP); // 付款金额 - 实际消费金额,如果有剩余,需要走退款操作 当使用余额支付时payAmount = principalPay + giftPay BigDecimal payAmount = orderBasicInfo.getPayAmount(); + if (payAmount == null || payAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("订单支付金额为空,禁止进入结算: " + orderCode); + } // logger.info("结算订单:【{}】, 支付金额:{}, 消费金额:{}", orderCode, payAmount, orderAmount); // 有时候充电桩到达金额停止充电会多出一点金额,比如实际需要充50元的电,充电桩传来的消费金额为50.01元,在后台记录的时候需要舍去 @@ -280,6 +307,9 @@ public abstract class AbstractProgramLogic implements InitializingBean { public OrderDetail returnUpdateOrderDetail(OrderBasicInfo orderBasicInfo, TransactionRecordsData data) { logger.info("start更新订单详情:【{}】, orderBasicInfo:{}, TransactionRecordsData:{}", orderBasicInfo.getOrderCode(), JSON.toJSONString(orderBasicInfo), JSON.toJSONString(data)); String orderCode = orderBasicInfo.getOrderCode(); + if (orderBasicInfo.getPayAmount() == null || orderBasicInfo.getPayAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("订单支付金额为空,禁止进入结算: " + orderCode); + } BigDecimal orderAmount = orderBasicInfo.getOrderAmount(); // 更新订单详情 查询订单详情 修改订单数据 OrderDetail orderDetail = orderBasicInfoService.getOrderDetailByOrderCode(orderCode); @@ -480,6 +510,9 @@ public abstract class AbstractProgramLogic implements InitializingBean { String orderCode = orderBasicInfo.getOrderCode(); // 订单支付金额 BigDecimal payAmount = orderBasicInfo.getPayAmount(); + if (payAmount == null || payAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("订单支付金额为空,禁止进入结算: " + orderCode); + } // data中的消费金额 精确到小数点后四位,包含电费、 服务费 BigDecimal dataOrderAmount = new BigDecimal(data.getConsumptionAmount()).setScale(2, RoundingMode.HALF_UP); // data中的总用电量 diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java index e1a5d3e25..53bde990d 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java @@ -32,6 +32,7 @@ import com.jsowell.pile.domain.*; import com.jsowell.pile.domain.ebike.deviceupload.EBikeMessageCmd03; import com.jsowell.pile.dto.*; import com.jsowell.pile.transaction.dto.OrderTransactionDTO; +import com.jsowell.pile.util.ChargeAmountUtils; import com.jsowell.pile.vo.uniapp.customer.MemberVO; import com.jsowell.pile.vo.uniapp.customer.PileConnectorDetailVO; import com.jsowell.pile.vo.web.BalanceDeductionAmountVO; @@ -321,14 +322,24 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { OrderBasicInfo orderBasicInfo = orderBasicInfoService.getOrderInfoByOrderCode(orderCode); // 订单所属运营商id String merchantId = orderBasicInfo.getMerchantId(); - // 支付金额 - BigDecimal chargeAmount = dto.getPayAmount(); // 会员id String memberId = dto.getMemberId(); // 查询该会员的余额 在所属运营商的余额 MemberVO memberVO = memberBasicInfoService.queryMemberInfoByMemberId(dto.getMemberId(), merchantId); + if (memberVO == null) { + throw new BusinessException(ReturnCodeEnum.CODE_GET_MEMBER_ACCOUNT_AMOUNT_ERROR); + } + // 支付金额:前端金额 > 会员默认金额 > 代码默认金额 + BigDecimal chargeAmount = ChargeAmountUtils.resolveStartupAmount( + dto.getPayAmount(), memberVO.getDefaultChargeAmount()); + if (!ChargeAmountUtils.isPositive(chargeAmount)) { + throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR.getValue(), "启动金额必须大于0"); + } + dto.setPayAmount(chargeAmount); // 总余额 - BigDecimal totalAccountAmount = memberVO.getTotalBalance(); + BigDecimal totalAccountAmount = memberVO.getTotalBalance() == null + ? BigDecimal.ZERO + : memberVO.getTotalBalance(); if (totalAccountAmount.compareTo(chargeAmount) < 0) { // 总余额小于充电金额 throw new BusinessException(ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT); @@ -542,6 +553,9 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { logger.info("结算订单V2:{}, 是订单完成状态", orderBasicInfo.getOrderCode()); return; } + if (!ensureOrderPaymentReadyForSettlement(orderBasicInfo, data)) { + return; + } // 查询订单详情 OrderDetail orderDetail = orderBasicInfoService.getOrderDetailByOrderCode(orderBasicInfo.getOrderCode()); @@ -606,6 +620,9 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { logger.info("结算订单:{}, 是订单完成状态", orderBasicInfo.getOrderCode()); return; } + if (!ensureOrderPaymentReadyForSettlement(orderBasicInfo, null)) { + return; + } // 查询订单信息 OrderDetail orderDetail = orderBasicInfoService.getOrderDetailByOrderCode(orderBasicInfo.getOrderCode()); // 订单消费金额, 除了充满自停是需要计算实际消费, 其他金额都是按次收费 @@ -759,6 +776,9 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { logger.info("结算订单:{}, 是订单完成状态", orderBasicInfo.getOrderCode()); return; } + if (!ensureOrderPaymentReadyForSettlement(orderBasicInfo, data)) { + return; + } // 获取更新数据后的orderBasicInfo对象 returnUpdateOrderBasicInfo(orderBasicInfo, data); diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java index 8235ca39d..5d6fd34e9 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java @@ -31,6 +31,7 @@ import com.jsowell.pile.domain.*; import com.jsowell.pile.domain.ebike.deviceupload.EBikeMessageCmd03; import com.jsowell.pile.dto.*; import com.jsowell.pile.transaction.dto.OrderTransactionDTO; +import com.jsowell.pile.util.ChargeAmountUtils; import com.jsowell.pile.vo.uniapp.customer.MemberVO; import com.jsowell.pile.vo.web.BalanceDeductionAmountVO; import com.jsowell.pile.vo.web.OrderDetailInfoVO; @@ -233,14 +234,24 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { OrderBasicInfo orderBasicInfo = orderBasicInfoService.getOrderInfoByOrderCode(orderCode); // 订单所属运营商id String merchantId = orderBasicInfo.getMerchantId(); - // 支付金额 - BigDecimal chargeAmount = dto.getPayAmount(); // 会员id String memberId = dto.getMemberId(); // 查询该会员的余额 在所属运营商的余额 MemberVO memberVO = memberBasicInfoService.queryMemberInfoByMemberId(memberId, merchantId); + if (memberVO == null) { + throw new BusinessException(ReturnCodeEnum.CODE_GET_MEMBER_ACCOUNT_AMOUNT_ERROR); + } + // 支付金额:前端金额 > 会员默认金额 > 代码默认金额 + BigDecimal chargeAmount = ChargeAmountUtils.resolveStartupAmount( + dto.getPayAmount(), memberVO.getDefaultChargeAmount()); + if (!ChargeAmountUtils.isPositive(chargeAmount)) { + throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR.getValue(), "启动金额必须大于0"); + } + dto.setPayAmount(chargeAmount); // 总余额 - BigDecimal totalAccountAmount = memberVO.getTotalBalance(); + BigDecimal totalAccountAmount = memberVO.getTotalBalance() == null + ? BigDecimal.ZERO + : memberVO.getTotalBalance(); if (totalAccountAmount.compareTo(chargeAmount) < 0) { // 总余额小于充电金额 throw new BusinessException(ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT); @@ -251,7 +262,7 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { // 赠送金支付金额 BigDecimal giftBalancePay = BigDecimal.ZERO; // 计算下发金额 - Map stringBigDecimalMap = calculateTheAmount(memberVO); + Map stringBigDecimalMap = calculateTheAmount(memberVO, chargeAmount); if (stringBigDecimalMap != null) { principalBalancePay = stringBigDecimalMap.get("principalBalancePay"); giftBalancePay = stringBigDecimalMap.get("giftBalancePay"); @@ -427,6 +438,9 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { logger.info("结算订单:{}, 是订单完成状态", orderBasicInfo.getOrderCode()); return; } + if (!ensureOrderPaymentReadyForSettlement(orderBasicInfo, data)) { + return; + } // 获取更新数据后的orderBasicInfo对象 // returnUpdateOrderBasicInfo(orderBasicInfo, data); @@ -495,6 +509,9 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { logger.info("结算订单:{}, 是订单完成状态", orderBasicInfo.getOrderCode()); return; } + if (!ensureOrderPaymentReadyForSettlement(orderBasicInfo, data)) { + return; + } // 获取更新数据后的orderBasicInfo对象 returnUpdateOrderBasicInfo(orderBasicInfo, data); diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/util/ChargeAmountUtils.java b/jsowell-pile/src/main/java/com/jsowell/pile/util/ChargeAmountUtils.java new file mode 100644 index 000000000..31955c1a9 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/util/ChargeAmountUtils.java @@ -0,0 +1,35 @@ +package com.jsowell.pile.util; + +import com.jsowell.common.constant.Constants; + +import java.math.BigDecimal; + +/** + * 启动充电金额工具类。 + */ +public final class ChargeAmountUtils { + + private ChargeAmountUtils() { + } + + /** + * 按“前端金额 > 会员默认金额 > 代码默认金额”的优先级确定启动金额。 + * + * @param frontendChargeAmount 前端传入的启动金额 + * @param memberDefaultChargeAmount 会员配置的默认启动金额 + * @return 最终启动金额 + */ + public static BigDecimal resolveStartupAmount(BigDecimal frontendChargeAmount, BigDecimal memberDefaultChargeAmount) { + if (frontendChargeAmount != null) { + return frontendChargeAmount; + } + if (isPositive(memberDefaultChargeAmount)) { + return memberDefaultChargeAmount; + } + return Constants.BALANCE_PAY_MAX_AMOUNT_NEW; + } + + public static boolean isPositive(BigDecimal amount) { + return amount != null && amount.compareTo(BigDecimal.ZERO) > 0; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderPaymentCheckResult.java b/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderPaymentCheckResult.java new file mode 100644 index 000000000..1cd0b87f8 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderPaymentCheckResult.java @@ -0,0 +1,40 @@ +package com.jsowell.pile.vo.web; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 订单支付凭证校验结果。 + * + *

paymentEvidenceFound 表示底层支付流水是否真实存在; + * orderPaymentReady 表示订单主表字段是否已经可以安全参与后续业务。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderPaymentCheckResult { + + /** 本次校验是否正常完成;数据库/依赖异常时为 false,不能据此判定订单未支付 */ + private boolean checkCompleted; + + /** 是否存在真实支付凭证 */ + private boolean paymentEvidenceFound; + + /** 订单支付字段是否完整且一致 */ + private boolean orderPaymentReady; + + /** 从支付流水恢复出的支付金额 */ + private BigDecimal paymentAmount; + + /** 从支付流水恢复出的支付时间 */ + private Date paymentTime; + + /** 校验失败原因 */ + private String reason; +} diff --git a/jsowell-pile/src/main/resources/mapper/pile/MemberBasicInfoMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/MemberBasicInfoMapper.xml index 175d65e14..5d0703f4d 100644 --- a/jsowell-pile/src/main/resources/mapper/pile/MemberBasicInfoMapper.xml +++ b/jsowell-pile/src/main/resources/mapper/pile/MemberBasicInfoMapper.xml @@ -320,6 +320,7 @@ + + + update order_basic_info set order_status = #{orderStatus,jdbcType=VARCHAR} diff --git a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java index da1e6c748..fec2aabf9 100644 --- a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java +++ b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java @@ -134,6 +134,8 @@ public class JsowellTask { // 查询出最近2天支付成功,并且订单状态为未启动的订单 String startTime = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, DateUtils.addDays(new Date(), -2)); String endTime = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new Date()); + // 先补偿/隔离支付凭证异常订单,避免后续启动失败任务把无支付订单当成正常订单结算退款。 + orderBasicInfoService.reconcileChargingOrderPayments(startTime, endTime); orderBasicInfoService.closeStartFailedOrder(startTime, endTime); }